Back to blog

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

software-architecturedesign-patternsenterprisebackendsystem-design
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 @Service classes 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

SituationTransaction 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 placeOrder and calculateQuote are 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

SituationDomain 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

SituationTable 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

AspectTable ModuleDomain Model
GranularityOne object per tableOne object per row/entity
IdentityWorks with record setsEach object has identity
BehaviorMethods operate on collectionsMethods operate on single instance
ORM fitWorks with DataSets, raw queriesWorks with JPA, Hibernate, Prisma
ComplexityMediumHigh
TestingTest with in-memory datasetsTest 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)?

AspectApplication ServiceDomain Service
PurposeCoordinate workflowEncapsulate domain logic that doesn't belong to a single entity
Knows aboutRepositories, events, email, transactionsOnly domain objects
ExampleplaceOrder() — loads entities, calls domain methods, saves, publishes eventsPricingService.calculateDiscount(order, promotions) — pure domain logic
DependenciesInfrastructure + domainDomain only
TestabilityNeeds mocks for infrastructurePure 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

AspectTransaction ScriptTable ModuleDomain ModelService Layer
ComplexityLowMediumHighAdds coordination
Best forSimple opsData-centricComplex domainsAll (as facade)
Duplication riskHighMediumLowN/A
TestabilityHard (DB needed)MediumHigh (pure objects)Medium (mock deps)
Learning curveMinimalLowSteepLow
OOP alignmentProceduralMixedFull OOPProcedural facade
FrameworkExpress handlers.NET DataSetSpring/JPA, DDD@Service, NestJS

Real-World Framework Mapping

FrameworkDefault PatternWhy
Express.jsTransaction ScriptRoute handlers as procedures, no built-in ORM
RailsActive Record + Transaction ScriptConvention favors simple procedural logic in models
DjangoActive Record + Transaction ScriptSimilar to Rails — views as scripts, models as data
Spring BootService Layer + Domain ModelDesigned for complex enterprise domains
NestJSService Layer + Transaction ScriptServices as providers, but typically procedural
ASP.NET CoreService Layer + Domain ModelEnterprise-oriented with EF Core
LaravelActive Record + Transaction ScriptEloquent 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 subdomains

Mistake 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 Model

Mistake 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 anemic

Mistake 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 domain

Practice 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 OrderService expose?
  • What operations does ProductService expose?
  • Where does inventory check logic live — in the service or the domain?

Exercise 4: Compare Frameworks

Build the same order-placement feature using:

  1. Express.js (Transaction Script style)
  2. 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.



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.