References

Domain Service

Concept

A Domain Service encapsulates business logic that doesn’t naturally belong to a single Entity or Value Object. It operates on multiple domain objects — often across aggregates — and expresses a business operation that involves coordination between them.

Domain Services are stateless: they hold no instance variables beyond injected dependencies, and their result depends only on the domain objects passed in.


Key Characteristics

  • Stateless — no mutable state; behavior depends entirely on inputs
  • Cross-aggregate — operates on two or more aggregates or domain objects
  • Named after domain operationsPricingService, TransferService, AuthorizationService
  • Lives in the domain layer — no infrastructure dependencies (no DB, no HTTP)
  • Not an application service — domain services contain business rules; application services orchestrate use cases

When to Use

Extract to a Domain Service when:

  • The operation involves multiple aggregates that shouldn’t know about each other
  • The operation is a meaningful concept in the domain but has no clear “home” in a single entity
  • Placing the logic in an entity would create an awkward dependency (Order.calculateWith(customer: Customer) means Order now depends on Customer)

Don’t use Domain Service when:

  • The logic naturally belongs to a single entity or value object — keep it there
  • The logic is application orchestration (sequence of use case steps) — use an Application Service instead
  • The operation requires infrastructure (DB queries, HTTP calls) — use a repository or an infrastructure service

Core Patterns

PricingService — cross-aggregate discount calculation

// domain/services/PricingService.ts
import { Order } from '../aggregates/Order';
import { Customer } from '../aggregates/Customer';
import { Money } from '../value-objects/Money';

export class PricingService {
  // Business rules: VIP discount, volume discount — spans Order and Customer
  calculateDiscount(order: Order, customer: Customer): Money {
    const total = order.total;

    // Rule 1: VIP customers get 10% off
    if (customer.isVip) {
      return total.multiply(0.10);
    }

    // Rule 2: Orders over $500 get 5% off
    if (total.amount > 500) {
      return total.multiply(0.05);
    }

    // Rule 3: First-time customers get $10 off orders over $50
    if (customer.isFirstOrder && total.amount > 50) {
      return new Money(10, total.currency);
    }

    return new Money(0, total.currency);
  }

  applyDiscount(order: Order, customer: Customer): Money {
    const discount = this.calculateDiscount(order, customer);
    return order.total.subtract(discount);
  }
}

TransferService — cross-aggregate funds transfer

// domain/services/TransferService.ts
import { Account } from '../aggregates/Account';
import { Money } from '../value-objects/Money';

export class TransferService {
  transfer(from: Account, to: Account, amount: Money): void {
    // Business rule: both accounts must be in the same currency
    if (!from.balance.currency === to.balance.currency) {
      throw new Error('Cross-currency transfers require explicit conversion');
    }

    from.debit(amount);   // Account enforces its own invariants (insufficient funds)
    to.credit(amount);    // Each aggregate guards its own state
  }
}

AuthorizationService — policy-based access control

// domain/services/AuthorizationService.ts
import { User } from '../entities/User';
import { Order } from '../aggregates/Order';

export class OrderAuthorizationService {
  canModify(user: User, order: Order): boolean {
    // Business rule: only the order's customer or an admin can modify it
    return user.id === order.customerId || user.hasRole('admin');
  }

  canCancel(user: User, order: Order): boolean {
    return this.canModify(user, order) && order.status !== 'shipped';
  }
}

Using Domain Service in an Application Service

// application/use-cases/CheckoutOrder.ts
export class CheckoutOrderUseCase {
  constructor(
    private orderRepo: IOrderRepository,
    private customerRepo: ICustomerRepository,
    private pricingService: PricingService,        // domain service injected
    private eventBus: IEventBus,
  ) {}

  async execute(orderId: string, customerId: string): Promise<Money> {
    const [order, customer] = await Promise.all([
      this.orderRepo.findById(orderId),
      this.customerRepo.findById(customerId),
    ]);

    if (!order || !customer) throw new Error('Order or Customer not found');

    // Domain service applies cross-aggregate business rule
    const finalPrice = this.pricingService.applyDiscount(order, customer);

    order.confirm();
    await this.orderRepo.save(order);

    for (const event of order.pullEvents()) {
      await this.eventBus.publish(event);
    }

    return finalPrice;
  }
}

Domain Service vs Application Service

Domain ServiceApplication Service
ContainsBusiness rules and domain logicOrchestration steps
LayerDomainApplication
DependenciesOnly domain objectsRepos, domain services, event bus
StatefulNoNo
ExamplePricingService.calculateDiscount(order, customer)CheckoutOrderUseCase.execute(orderId, customerId)

Common Mistakes

❌ Infrastructure in a Domain Service:

// WRONG: domain service makes a DB call
class PricingService {
  async calculateDiscount(orderId: string): Promise<Money> {
    const order = await this.db.order.findUnique({ where: { id: orderId } }); // DB!
    ...
  }
}

// CORRECT: domain service receives already-loaded domain objects
class PricingService {
  calculateDiscount(order: Order, customer: Customer): Money {
    // pure domain logic — no infrastructure
  }
}

❌ Stateful Domain Service:

// WRONG: storing state in a domain service
class PricingService {
  private lastCalculatedPrice: Money; // stateful!

  calculateDiscount(order: Order): Money {
    this.lastCalculatedPrice = ...; // shared state breaks concurrent usage
    return this.lastCalculatedPrice;
  }
}

// CORRECT: stateless — result depends only on inputs
class PricingService {
  calculateDiscount(order: Order, customer: Customer): Money {
    return ...; // pure function — no side effects, no stored state
  }
}

❌ Logic that belongs in an Entity placed in a Domain Service:

// WRONG: Order.confirm() logic moved to a service
class OrderService {
  confirm(order: Order): void {
    if (order.items.length === 0) throw new Error('Empty order');
    order.status = 'confirmed'; // breaking encapsulation
  }
}

// CORRECT: invariants belong in the aggregate
class Order {
  confirm(): void {
    if (this._items.length === 0) throw new Error('Cannot confirm empty order');
    this._status = 'confirmed';
  }
}