Domain Logic Patterns: Transaction Script, Domain Model, Table Module & Service Layer

Every enterprise application needs to answer one fundamental question: where does the business logic live?
It seems simple, but the answer shapes everything — how maintainable the code is, how testable it is, how well it scales with growing complexity, and how easy it is for new team members to understand.
Martin Fowler identified four patterns for organizing domain logic in his 2003 book Patterns of Enterprise Application Architecture. These aren't theoretical constructs — they're the actual patterns that power every framework you use today:
- Your Express.js route handlers? Transaction Script.
- Spring Boot with rich JPA entities? Domain Model.
- .NET DataSet/DataTable? Table Module.
- The
@Serviceclasses wrapping your business logic? Service Layer.
In this post, we'll cover:
✅ Transaction Script — procedural, one script per operation
✅ Domain Model — object-oriented, behavior lives in domain objects
✅ Table Module — one class per table, operates on record sets
✅ Service Layer — thin facade coordinating domain logic
✅ Rich vs anemic domain model — the most common anti-pattern
✅ When to use each pattern — a decision framework
✅ How patterns evolve as complexity grows
✅ Real-world code examples in TypeScript and Java
The Domain Logic Spectrum
Before diving into each pattern, understand that they exist on a spectrum of domain complexity:
The right choice depends on your domain's complexity. Most projects start with Transaction Script and evolve toward Domain Model as the business rules grow.
Pattern 1: Transaction Script
What It Is
Transaction Script organizes business logic as a single procedure per business operation. Each operation — place an order, transfer money, approve a request — gets its own function that does everything: validates input, applies rules, persists data, and returns a result.
TypeScript Example
// Transaction Script: one function = one business operation
class OrderService {
constructor(private db: Database) {}
async placeOrder(
customerId: string,
items: { productId: string; quantity: number }[]
): Promise<Order> {
// Step 1: Validate customer
const customer = await this.db.query(
'SELECT * FROM customers WHERE id = $1',
[customerId]
);
if (!customer) throw new Error('Customer not found');
// Step 2: Check inventory and calculate total
let total = 0;
for (const item of items) {
const product = await this.db.query(
'SELECT * FROM products WHERE id = $1',
[item.productId]
);
if (!product) throw new Error(`Product ${item.productId} not found`);
if (product.stock < item.quantity) {
throw new Error(`Insufficient stock for ${product.name}`);
}
total += product.price * item.quantity;
}
// Step 3: Apply discount rules
if (total > 100) {
total *= 0.9; // 10% discount over $100
}
if (customer.tier === 'gold') {
total *= 0.95; // extra 5% for gold customers
}
// Step 4: Create order and update inventory
const order = await this.db.query(
'INSERT INTO orders (customer_id, total, status) VALUES ($1, $2, $3) RETURNING *',
[customerId, total, 'confirmed']
);
for (const item of items) {
await this.db.query(
'INSERT INTO order_items (order_id, product_id, quantity) VALUES ($1, $2, $3)',
[order.id, item.productId, item.quantity]
);
await this.db.query(
'UPDATE products SET stock = stock - $1 WHERE id = $2',
[item.quantity, item.productId]
);
}
return order;
}
async cancelOrder(orderId: string): Promise<void> {
// Another Transaction Script — separate procedure
const order = await this.db.query(
'SELECT * FROM orders WHERE id = $1',
[orderId]
);
if (!order) throw new Error('Order not found');
if (order.status === 'shipped') {
throw new Error('Cannot cancel shipped order');
}
// Restore inventory
const items = await this.db.query(
'SELECT * FROM order_items WHERE order_id = $1',
[orderId]
);
for (const item of items) {
await this.db.query(
'UPDATE products SET stock = stock + $1 WHERE id = $2',
[item.quantity, item.productId]
);
}
await this.db.query(
'UPDATE orders SET status = $1 WHERE id = $2',
['cancelled', orderId]
);
}
}Java Example
@Service
@Transactional
public class OrderService {
private final JdbcTemplate jdbc;
public OrderService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public Order placeOrder(String customerId, List<OrderItemRequest> items) {
// Step 1: Validate customer
var customer = jdbc.queryForObject(
"SELECT * FROM customers WHERE id = ?",
new CustomerRowMapper(), customerId
);
// Step 2: Check inventory and calculate total
BigDecimal total = BigDecimal.ZERO;
for (var item : items) {
var product = jdbc.queryForObject(
"SELECT * FROM products WHERE id = ?",
new ProductRowMapper(), item.productId()
);
if (product.stock() < item.quantity()) {
throw new InsufficientStockException(product.name());
}
total = total.add(product.price().multiply(BigDecimal.valueOf(item.quantity())));
}
// Step 3: Apply discount rules
if (total.compareTo(BigDecimal.valueOf(100)) > 0) {
total = total.multiply(BigDecimal.valueOf(0.9));
}
if ("gold".equals(customer.tier())) {
total = total.multiply(BigDecimal.valueOf(0.95));
}
// Step 4: Persist
var orderId = jdbc.queryForObject(
"INSERT INTO orders (customer_id, total, status) VALUES (?, ?, ?) RETURNING id",
String.class, customerId, total, "confirmed"
);
for (var item : items) {
jdbc.update(
"INSERT INTO order_items (order_id, product_id, quantity) VALUES (?, ?, ?)",
orderId, item.productId(), item.quantity()
);
jdbc.update(
"UPDATE products SET stock = stock - ? WHERE id = ?",
item.quantity(), item.productId()
);
}
return new Order(orderId, customerId, total, "confirmed");
}
}When to Use Transaction Script
| Situation | Transaction Script? |
|---|---|
| Simple CRUD with minimal business rules | ✅ Yes |
| Each operation is independent, few shared rules | ✅ Yes |
| Small team, rapid prototyping | ✅ Yes |
| Business rules are complex and interconnected | ❌ No — use Domain Model |
| Rules change frequently and vary by context | ❌ No — use Domain Model |
| Domain experts describe complex workflows | ❌ No — use Domain Model |
Strengths and Weaknesses
Strengths:
- Simple to understand — each procedure reads top to bottom
- Easy to trace — one function, one operation, one flow
- Low overhead — no ORM, no entity mapping, no object graph
- Works well with raw SQL and functional programming styles
Weaknesses:
- Code duplication — discount rules in
placeOrderandcalculateQuoteare copy-pasted - No model — the "order" concept exists only as database rows, not as an object with behavior
- Hard to test — testing requires a database because logic is intertwined with SQL
- Grows into "God scripts" — as rules accumulate, a single function becomes 500+ lines
Pattern 2: Domain Model
What It Is
Domain Model organizes business logic as a network of interconnected objects where each object represents a meaningful concept in the domain. Objects have both data and behavior — they don't just hold state, they enforce rules.
This is the pattern that Domain-Driven Design builds on. DDD's entities, value objects, and aggregates are specific implementations of the Domain Model pattern.
TypeScript Example
// Value Object: immutable, compared by value
class Money {
constructor(
readonly amount: number,
readonly currency: string = 'USD'
) {
if (amount < 0) throw new Error('Money cannot be negative');
}
add(other: Money): Money {
this.assertSameCurrency(other);
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(
Math.round(this.amount * factor * 100) / 100,
this.currency
);
}
isGreaterThan(other: Money): boolean {
this.assertSameCurrency(other);
return this.amount > other.amount;
}
private assertSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error(`Cannot combine ${this.currency} with ${other.currency}`);
}
}
}
// Entity: identified by ID, has behavior
class OrderItem {
constructor(
readonly product: Product,
private _quantity: number
) {
if (_quantity <= 0) throw new Error('Quantity must be positive');
if (_quantity > product.stock) {
throw new Error(`Insufficient stock for ${product.name}`);
}
}
get quantity(): number { return this._quantity; }
get lineTotal(): Money {
return this.product.price.multiply(this._quantity);
}
}
// Aggregate root: the entry point for all order operations
class Order {
private _items: OrderItem[] = [];
private _status: OrderStatus = 'draft';
constructor(
readonly id: string,
readonly customer: Customer
) {}
get status(): OrderStatus { return this._status; }
get items(): ReadonlyArray<OrderItem> { return this._items; }
// Business logic LIVES here, not in a service
addItem(product: Product, quantity: number): void {
if (this._status !== 'draft') {
throw new Error('Cannot modify a confirmed order');
}
const existing = this._items.find(i => i.product.id === product.id);
if (existing) {
throw new Error(`Product ${product.name} already in order — modify quantity instead`);
}
this._items.push(new OrderItem(product, quantity));
}
calculateTotal(): Money {
const subtotal = this._items.reduce(
(sum, item) => sum.add(item.lineTotal),
new Money(0)
);
return this.applyDiscounts(subtotal);
}
confirm(): void {
if (this._status !== 'draft') {
throw new Error(`Cannot confirm order in ${this._status} status`);
}
if (this._items.length === 0) {
throw new Error('Cannot confirm empty order');
}
this._status = 'confirmed';
// Domain event could be raised here
}
cancel(): void {
if (this._status === 'shipped') {
throw new Error('Cannot cancel shipped order');
}
this._status = 'cancelled';
}
// Private: discount rules are encapsulated in the domain
private applyDiscounts(subtotal: Money): Money {
let total = subtotal;
// Volume discount
if (total.isGreaterThan(new Money(100))) {
total = total.multiply(0.9);
}
// Customer tier discount
if (this.customer.tier === 'gold') {
total = total.multiply(0.95);
}
return total;
}
}
type OrderStatus = 'draft' | 'confirmed' | 'shipped' | 'cancelled';Java Example
// Value Object
public record Money(BigDecimal amount, String currency) {
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Money cannot be negative");
}
}
public Money add(Money other) {
assertSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
public Money multiply(double factor) {
return new Money(
amount.multiply(BigDecimal.valueOf(factor))
.setScale(2, RoundingMode.HALF_UP),
currency
);
}
public boolean isGreaterThan(Money other) {
assertSameCurrency(other);
return amount.compareTo(other.amount) > 0;
}
private void assertSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Cannot combine " + currency + " with " + other.currency
);
}
}
}
// Aggregate root
public class Order {
private final String id;
private final Customer customer;
private final List<OrderItem> items = new ArrayList<>();
private OrderStatus status = OrderStatus.DRAFT;
public Order(String id, Customer customer) {
this.id = id;
this.customer = customer;
}
// Business logic lives in the entity
public void addItem(Product product, int quantity) {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot modify a confirmed order");
}
boolean exists = items.stream()
.anyMatch(i -> i.getProduct().getId().equals(product.getId()));
if (exists) {
throw new IllegalArgumentException("Product already in order");
}
items.add(new OrderItem(product, quantity));
}
public Money calculateTotal() {
Money subtotal = items.stream()
.map(OrderItem::lineTotal)
.reduce(new Money(BigDecimal.ZERO, "USD"), Money::add);
return applyDiscounts(subtotal);
}
public void confirm() {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot confirm order in " + status);
}
if (items.isEmpty()) {
throw new IllegalStateException("Cannot confirm empty order");
}
this.status = OrderStatus.CONFIRMED;
}
public void cancel() {
if (status == OrderStatus.SHIPPED) {
throw new IllegalStateException("Cannot cancel shipped order");
}
this.status = OrderStatus.CANCELLED;
}
private Money applyDiscounts(Money subtotal) {
Money total = subtotal;
if (total.isGreaterThan(new Money(BigDecimal.valueOf(100), "USD"))) {
total = total.multiply(0.9);
}
if (customer.getTier() == CustomerTier.GOLD) {
total = total.multiply(0.95);
}
return total;
}
}The Anemic Domain Model Anti-Pattern
The most common mistake when attempting Domain Model is creating an anemic domain model — objects that have data but no behavior. All the logic ends up in service classes, and the "domain objects" are just data bags.
// ❌ ANEMIC: domain object is just a data bag
class Order {
id: string;
customerId: string;
items: OrderItem[];
total: number;
status: string;
// No behavior — just getters and setters
}
// ❌ All logic leaks into the service
class OrderService {
placeOrder(order: Order, items: OrderItem[]): void {
// Business rules are here, not in the Order
order.items = items;
order.total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
if (order.total > 100) order.total *= 0.9;
order.status = 'confirmed';
this.repository.save(order);
}
}// ✅ RICH: domain object owns its behavior
class Order {
private _items: OrderItem[] = [];
private _status: OrderStatus = 'draft';
// Logic is HERE, in the domain object
addItem(product: Product, quantity: number): void {
// validates, enforces rules
}
calculateTotal(): Money {
// encapsulates discount logic
}
confirm(): void {
// guards state transitions
}
}
// Service is thin — just coordinates
class OrderApplicationService {
async placeOrder(customerId: string, itemRequests: ItemRequest[]): Promise<OrderId> {
const customer = await this.customerRepo.findById(customerId);
const order = new Order(generateId(), customer);
for (const req of itemRequests) {
const product = await this.productRepo.findById(req.productId);
order.addItem(product, req.quantity); // Domain object enforces rules
}
order.confirm(); // Domain object guards state transition
await this.orderRepo.save(order);
return order.id;
}
}Martin Fowler on Anemic Domain Model: "The fundamental horror of this anti-pattern is that it's so contrary to the basic idea of object-oriented design; which is to combine data and process together."
When to Use Domain Model
| Situation | Domain Model? |
|---|---|
| Complex business rules that interact | ✅ Yes |
| Rules change frequently | ✅ Yes |
| Multiple paths through a business process | ✅ Yes |
| Domain experts describe nuanced workflows | ✅ Yes |
| Simple CRUD with minimal rules | ❌ No — Transaction Script is simpler |
| Data-centric reporting application | ❌ No — Table Module may fit better |
Pattern 3: Table Module
What It Is
Table Module organizes business logic as one class per database table, where the class operates on a collection of rows rather than a single entity instance. Unlike Domain Model (one object per row), Table Module gives you one object for the entire table.
This pattern was popularized by .NET's DataSet/DataTable and is a middle ground between Transaction Script and Domain Model.
TypeScript Example
// Table Module: one class per table, operates on record sets
class OrderModule {
constructor(private dataSet: Map<string, Record<string, unknown>[]>) {}
// Operates on the order dataset
calculateTotal(orderId: string): number {
const orderItems = this.dataSet.get('order_items')
?.filter(row => row.orderId === orderId) ?? [];
const products = this.dataSet.get('products') ?? [];
let total = 0;
for (const item of orderItems) {
const product = products.find(p => p.id === item.productId);
if (product) {
total += (product.price as number) * (item.quantity as number);
}
}
return total;
}
applyDiscount(orderId: string, customerTier: string): number {
let total = this.calculateTotal(orderId);
if (total > 100) total *= 0.9;
if (customerTier === 'gold') total *= 0.95;
// Update the dataset
const orders = this.dataSet.get('orders') ?? [];
const order = orders.find(o => o.id === orderId);
if (order) order.total = total;
return total;
}
getOrdersByStatus(status: string): Record<string, unknown>[] {
return (this.dataSet.get('orders') ?? [])
.filter(row => row.status === status);
}
}When to Use Table Module
| Situation | Table Module? |
|---|---|
| Data-centric application with record set operations | ✅ Yes |
| .NET environment with DataSet/DataTable | ✅ Yes |
| Reporting and batch processing | ✅ Yes |
| Business logic is moderate — more than CRUD, less than complex domain | ✅ Yes |
| Complex domain with rich entity behavior | ❌ No — use Domain Model |
| Need individual object identity and lifecycle | ❌ No — use Domain Model |
Table Module vs Domain Model
| Aspect | Table Module | Domain Model |
|---|---|---|
| Granularity | One object per table | One object per row/entity |
| Identity | Works with record sets | Each object has identity |
| Behavior | Methods operate on collections | Methods operate on single instance |
| ORM fit | Works with DataSets, raw queries | Works with JPA, Hibernate, Prisma |
| Complexity | Medium | High |
| Testing | Test with in-memory datasets | Test individual objects |
In practice, Table Module is less common today. Most modern frameworks (Spring, Rails, Django, NestJS) push you toward either Transaction Script (simple services with queries) or Domain Model (entities with behavior). Table Module lives mainly in the .NET DataSet world and legacy applications.
Pattern 4: Service Layer
What It Is
Service Layer defines the application's boundary — a set of available operations and the coordination logic for each operation. It's a thin facade that sits between the presentation layer and the domain logic.
The key word is thin. A Service Layer coordinates but doesn't contain business rules. It orchestrates domain objects, handles transactions, and dispatches notifications — but the actual rules live in the domain.
Service Layer + Domain Model (Most Common Combination)
// Application Service: coordinates, doesn't contain rules
class OrderApplicationService {
constructor(
private orderRepo: OrderRepository,
private customerRepo: CustomerRepository,
private productRepo: ProductRepository,
private eventBus: EventBus,
private emailService: EmailService
) {}
async placeOrder(command: PlaceOrderCommand): Promise<string> {
// 1. Load domain objects
const customer = await this.customerRepo.findById(command.customerId);
if (!customer) throw new NotFoundException('Customer not found');
// 2. Create and populate the aggregate
const order = new Order(generateId(), customer);
for (const item of command.items) {
const product = await this.productRepo.findById(item.productId);
if (!product) throw new NotFoundException(`Product ${item.productId} not found`);
order.addItem(product, item.quantity); // Domain object validates
}
// 3. Domain object enforces rules
order.confirm();
// 4. Persist (infrastructure concern)
await this.orderRepo.save(order);
// 5. Side effects (infrastructure concern)
await this.eventBus.publish(new OrderConfirmedEvent(order.id));
await this.emailService.sendOrderConfirmation(customer.email, order);
return order.id;
}
async cancelOrder(orderId: string): Promise<void> {
const order = await this.orderRepo.findById(orderId);
if (!order) throw new NotFoundException('Order not found');
order.cancel(); // Domain object guards this transition
await this.orderRepo.save(order);
await this.eventBus.publish(new OrderCancelledEvent(orderId));
}
}Service Layer + Transaction Script
When your domain is simple, the Service Layer and Transaction Script often merge into one:
// For simple domains, Service Layer IS the Transaction Script
class UserService {
constructor(
private userRepo: UserRepository,
private hasher: PasswordHasher,
private emailService: EmailService
) {}
async register(email: string, password: string): Promise<User> {
// Simple enough — no need for a rich User domain object
const existing = await this.userRepo.findByEmail(email);
if (existing) throw new ConflictError('Email already registered');
const hashedPassword = await this.hasher.hash(password);
const user = await this.userRepo.create({ email, hashedPassword });
await this.emailService.sendWelcome(email);
return user;
}
}Java Example: Spring Boot Service Layer
@Service
@Transactional
public class OrderApplicationService {
private final OrderRepository orderRepo;
private final CustomerRepository customerRepo;
private final ProductRepository productRepo;
private final ApplicationEventPublisher eventPublisher;
public OrderApplicationService(
OrderRepository orderRepo,
CustomerRepository customerRepo,
ProductRepository productRepo,
ApplicationEventPublisher eventPublisher
) {
this.orderRepo = orderRepo;
this.customerRepo = customerRepo;
this.productRepo = productRepo;
this.eventPublisher = eventPublisher;
}
public String placeOrder(PlaceOrderCommand command) {
var customer = customerRepo.findById(command.customerId())
.orElseThrow(() -> new NotFoundException("Customer not found"));
var order = new Order(UUID.randomUUID().toString(), customer);
for (var item : command.items()) {
var product = productRepo.findById(item.productId())
.orElseThrow(() -> new NotFoundException("Product not found"));
order.addItem(product, item.quantity()); // Domain validates
}
order.confirm(); // Domain guards state transition
orderRepo.save(order);
eventPublisher.publishEvent(new OrderConfirmedEvent(order.getId()));
return order.getId();
}
}Application Service vs Domain Service
A common confusion: what's the difference between an application service (Service Layer) and a domain service (part of Domain Model)?
| Aspect | Application Service | Domain Service |
|---|---|---|
| Purpose | Coordinate workflow | Encapsulate domain logic that doesn't belong to a single entity |
| Knows about | Repositories, events, email, transactions | Only domain objects |
| Example | placeOrder() — loads entities, calls domain methods, saves, publishes events | PricingService.calculateDiscount(order, promotions) — pure domain logic |
| Dependencies | Infrastructure + domain | Domain only |
| Testability | Needs mocks for infrastructure | Pure unit tests |
// Domain Service: pure domain logic, no infrastructure
class PricingService {
calculateDiscount(order: Order, promotions: Promotion[]): Money {
let discount = new Money(0);
for (const promo of promotions) {
if (promo.appliesTo(order)) {
discount = discount.add(promo.calculateAmount(order));
}
}
return discount;
}
}
// Application Service: coordinates infrastructure + domain
class OrderApplicationService {
async placeOrder(command: PlaceOrderCommand): Promise<string> {
const order = /* ... build order ... */;
const promos = await this.promoRepo.findActive();
// Delegate complex domain logic to a domain service
const discount = this.pricingService.calculateDiscount(order, promos);
order.applyDiscount(discount);
order.confirm();
await this.orderRepo.save(order);
return order.id;
}
}Choosing the Right Pattern
The Decision Framework
Comparison Table
| Aspect | Transaction Script | Table Module | Domain Model | Service Layer |
|---|---|---|---|---|
| Complexity | Low | Medium | High | Adds coordination |
| Best for | Simple ops | Data-centric | Complex domains | All (as facade) |
| Duplication risk | High | Medium | Low | N/A |
| Testability | Hard (DB needed) | Medium | High (pure objects) | Medium (mock deps) |
| Learning curve | Minimal | Low | Steep | Low |
| OOP alignment | Procedural | Mixed | Full OOP | Procedural facade |
| Framework | Express handlers | .NET DataSet | Spring/JPA, DDD | @Service, NestJS |
Real-World Framework Mapping
| Framework | Default Pattern | Why |
|---|---|---|
| Express.js | Transaction Script | Route handlers as procedures, no built-in ORM |
| Rails | Active Record + Transaction Script | Convention favors simple procedural logic in models |
| Django | Active Record + Transaction Script | Similar to Rails — views as scripts, models as data |
| Spring Boot | Service Layer + Domain Model | Designed for complex enterprise domains |
| NestJS | Service Layer + Transaction Script | Services as providers, but typically procedural |
| ASP.NET Core | Service Layer + Domain Model | Enterprise-oriented with EF Core |
| Laravel | Active Record + Transaction Script | Eloquent models with procedural service classes |
Pattern Evolution: How Projects Grow
Most projects don't stay with one pattern forever. As complexity grows, the domain logic pattern evolves:
Stage 1: Transaction Script (Day 1)
// Simple, fast, works. Ship it.
app.post('/orders', async (req, res) => {
const { customerId, items } = req.body;
// ... all logic in one function
const order = await db.query('INSERT INTO orders...');
res.json(order);
});Stage 2: Extract Service Layer (Month 3)
// Logic moved out of controller into a service
class OrderService {
async placeOrder(customerId: string, items: ItemRequest[]): Promise<Order> {
// Same Transaction Script, but now reusable and testable
}
}
app.post('/orders', async (req, res) => {
const order = await orderService.placeOrder(req.body.customerId, req.body.items);
res.json(order);
});Stage 3: Introduce Domain Model (Month 9)
// Business rules are getting complex — extract into domain objects
class Order {
addItem(product: Product, quantity: number): void { /* validates */ }
calculateTotal(): Money { /* complex discounts */ }
confirm(): void { /* state machine */ }
}
// Service Layer becomes thin coordinator
class OrderApplicationService {
async placeOrder(command: PlaceOrderCommand): Promise<string> {
const order = new Order(id, customer);
for (const item of command.items) {
order.addItem(product, item.quantity);
}
order.confirm();
await this.orderRepo.save(order);
return order.id;
}
}Stage 4: Full DDD (Year 2+)
// Aggregates, value objects, domain events, bounded contexts
class Order { /* aggregate root with full invariant enforcement */ }
class Money { /* value object with currency math */ }
class OrderConfirmedEvent { /* domain event for cross-context communication */ }The key insight: you don't need to start with Domain Model. Start with Transaction Script, and refactor toward Domain Model when the complexity demands it. This is what Martin Fowler calls evolutionary design.
Common Mistakes
Mistake 1: Premature Domain Model
❌ Building a rich Domain Model for a simple CRUD app
❌ Creating Value Objects and Aggregates for basic data storage
❌ Adding DDD patterns to a prototype that might not survive
✅ Start with Transaction Script
✅ Refactor to Domain Model when rules grow
✅ Only use DDD for core subdomainsMistake 2: God Transaction Script
❌ A single 800-line function handling an entire workflow
❌ No extraction of shared logic
❌ Deeply nested if-else chains
✅ Extract helper functions for shared rules
✅ Keep each script focused on one operation
✅ When scripts share too much logic, consider Domain ModelMistake 3: Anemic Domain Model
❌ Domain objects with only getters/setters
❌ All behavior in service classes
❌ "Domain Model" that's really Transaction Script with extra classes
✅ Business rules belong in domain objects
✅ Services coordinate, they don't contain rules
✅ If you can't test domain logic without infrastructure, it's anemicMistake 4: Fat Service Layer
❌ Service Layer that contains all business logic
❌ Domain objects are just data transfer objects
❌ Services with 50+ methods and 2000+ lines
✅ Service Layer is thin — coordinates, delegates, orchestrates
✅ Domain logic lives in domain objects or domain services
✅ If your service layer is fat, extract rules into the domainPractice Exercises
Exercise 1: Identify the Pattern
Look at a project you work on. For each service class, determine:
- Is it Transaction Script? (procedural, all logic in the method)
- Is it Domain Model? (delegates to rich domain objects)
- Is it an anemic Domain Model? (has "domain" objects, but logic is in services)
Exercise 2: Refactor Transaction Script to Domain Model
Take this Transaction Script and refactor it into a Domain Model with a thin Service Layer:
// Refactor this into a rich Order domain model
async function transferMoney(fromAccountId: string, toAccountId: string, amount: number) {
const from = await db.query('SELECT * FROM accounts WHERE id = $1', [fromAccountId]);
const to = await db.query('SELECT * FROM accounts WHERE id = $1', [toAccountId]);
if (from.balance < amount) throw new Error('Insufficient funds');
if (from.status !== 'active') throw new Error('Source account is not active');
if (to.status !== 'active') throw new Error('Target account is not active');
if (amount <= 0) throw new Error('Amount must be positive');
if (amount > 10000) throw new Error('Transfer limit exceeded');
await db.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, fromAccountId]);
await db.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, toAccountId]);
await db.query('INSERT INTO transfers (from_id, to_id, amount) VALUES ($1, $2, $3)',
[fromAccountId, toAccountId, amount]);
}Exercise 3: Design a Service Layer
For an e-commerce system, design the Service Layer interface:
- What operations does
OrderServiceexpose? - What operations does
ProductServiceexpose? - Where does inventory check logic live — in the service or the domain?
Exercise 4: Compare Frameworks
Build the same order-placement feature using:
- Express.js (Transaction Script style)
- Spring Boot (Domain Model + Service Layer style)
Compare: line count, testability, where rules live, how easy it is to add a new discount rule.
Summary and Key Takeaways
✅ Transaction Script — one procedure per operation. Simple, fast, works for CRUD. Degrades with complexity.
✅ Domain Model — business logic in domain objects. Best for complex rules. Requires discipline to avoid anemic models.
✅ Table Module — one class per table operating on record sets. Middle ground, mainly in .NET. Less common today.
✅ Service Layer — thin facade coordinating domain logic. Almost always used alongside Transaction Script or Domain Model.
✅ The most common combination in enterprise apps: Service Layer + Domain Model
✅ The most common anti-pattern: Anemic Domain Model (Domain Model structure with Transaction Script behavior)
✅ Start simple (Transaction Script), evolve when complexity demands it (Domain Model)
✅ Your framework already picked a default pattern — understand it and work with it, not against it
What's Next?
Now that you know how to organize business logic, the next question is: how do you persist it? In Post #3: Data Source Patterns, we'll cover Active Record, Data Mapper, Table Data Gateway, and Row Data Gateway — the patterns that connect your domain to the database.
Related Posts
- Patterns of Enterprise Application Architecture Roadmap — Series overview and learning paths
- Domain-Driven Design — DDD builds directly on the Domain Model pattern
- SOLID Principles Explained — Principles that make Domain Model effective
- Software Architecture Patterns Roadmap — System-level architecture context
- Clean Architecture — How domain logic fits inside clean architecture layers
- Hexagonal Architecture — Isolating domain logic from infrastructure
Series: Patterns of Enterprise Application Architecture
Next: Post #3 — Data Source Patterns (Active Record, Data Mapper, Table Data Gateway, Row Data Gateway)
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.