Siddhant Deval
Siddhant Deval
backend5 min read

Domain Services & The Specification Pattern: Complex Business Rules Without God Objects

Some business rules don't naturally belong to a single entity — discount calculation spanning multiple Order line items, subscription eligibility across a Customer's history, or fraud detection logic that crosses Aggregate boundaries. Domain Services and the Specification pattern encapsulate these complex rules as first-class, named, testable domain concepts.

Domain Services & The Specification Pattern: Complex Business Rules Without God Objects

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. But what happens when a business rule requires data from multiple Aggregates — data that no single Aggregate owns? An Order cannot calculate loyalty discount eligibility on its own because it does not know the customer's purchase history. A Customer cannot calculate the order total discount because it does not own the cart. When a business rule spans Aggregate boundaries, the logic belongs in a Domain Service. When that logic is composable, testable in isolation, and needs to be combined with other rules using AND/OR/NOT operators, it belongs in a Specification.

This article builds the DiscountEngine Domain Service and a set of composable ISpecification<T> implementations for the billing engine — including HasMinimumOrderAmount, IsLoyalCustomer, HasActiveSubscription, and their composition into loyaltyDiscountEligibility.

Architectural Note

Prerequisites: Part 3 (Entities & Value Objects) for Money, Order, and Customer; Part 4 (Aggregates) for the Aggregate boundary constraint that motivates placing this logic outside either Aggregate; Part 6 (Use Cases) for where Domain Services are called from.


1. The God Object Anti-Pattern

The billing engine's discount rules at month three of a real project:

TYPESCRIPT
// ❌ Anti-Pattern: God object — Order.calculateDiscount() knows too much
export class Order extends AggregateRoot<OrderId> {
  calculateDiscount(customer: Customer, allOrders: Order[]): Money {
    // Rule 1: VIP customers get 20%
    if (customer.tier === 'VIP') return this.total.multiply(0.20);

    // Rule 2: Orders over $500 get 10% bulk discount
    if (this.total.greaterThan(Money.of('USD', 500))) return this.total.multiply(0.10);

    // Rule 3: Customers with 5+ lifetime orders get loyalty discount
    const lifetimeOrders = allOrders.filter(o => o.customerId === customer.id);
    if (lifetimeOrders.length >= 5) return this.total.multiply(0.08);

    // Rule 4: Active Premium subscribers get 12%
    if (customer.subscriptions.some(s => s.plan === 'PREMIUM' && s.isActive())) {
      return this.total.multiply(0.12);
    }

    // Rule 5: Black Friday — hardcoded date check inside the domain object
    const now = new Date();
    if (now.getMonth() === 10 && now.getDate() === 29) return this.total.multiply(0.25);

    return Money.zero(this.total.currency);
  }
}

Five problems have already appeared in 20 lines:

  1. Order depends on Customer — but these are separate Aggregates. Order.calculateDiscount(customer) means the Order must receive and inspect another Aggregate's internal state.
  2. Order depends on all Orders — allOrders: Order[] is the entire order history of the customer. The Order Aggregate now requires a database query result as a parameter.
  3. Hardcoded Black Friday date — domain logic that depends on wall-clock time cannot be unit-tested deterministically.
  4. Rules are exclusive (if/else if) — the VIP rule fires OR the bulk rule fires, never both. But the business may want both simultaneously. Changing the priority requires modifying the Aggregate.
  5. Adding Rule 6 requires modifying Order — every new discount rule is a change to the most critical class in the domain model.

2. Domain Services: Cross-Aggregate Business Logic

A Domain Service encapsulates business logic that:

  • Operates on multiple Aggregates simultaneously
  • Has no natural home in any single Aggregate
  • Is stateless (it holds no data of its own)

The canonical test: "Can I explain which Aggregate owns this rule?" If yes, the logic belongs in that Aggregate. If no, it belongs in a Domain Service.

TYPESCRIPT
// src/domain/discount/DiscountCalculationService.ts
import { Order } from '../order/Order';
import { Customer } from '../customer/Customer';
import { Money } from '../payment/Money';
import { IDiscountStrategy } from './IDiscountStrategy';

export class DiscountCalculationService {
  /**
   * Domain Service: stateless, operates across Order and Customer boundaries.
   * Returns the best applicable discount amount (not a percentage — a concrete Money value).
   */
  bestDiscount(
    order: Order,
    customer: Customer,
    strategies: IDiscountStrategy[],
  ): Money {
    const applicable = strategies
      .filter(strategy => strategy.isApplicable(order, customer))
      .sort((a, b) => b.priority - a.priority); // Highest priority wins

    return applicable.length > 0
      ? applicable[0].apply(order.total)
      : Money.zero(order.total.currency);
  }

  /**
   * Stacking variant: apply ALL applicable discounts (additive model).
   * Used during promotional periods where multiple discounts can combine.
   */
  stackedDiscount(
    order: Order,
    customer: Customer,
    strategies: IDiscountStrategy[],
  ): Money {
    const applicable = strategies.filter(s => s.isApplicable(order, customer));

    return applicable.reduce(
      (totalDiscount, strategy) => {
        const strategyDiscount = strategy.apply(order.total).subtract(order.total);
        // Each strategy reduces from ORIGINAL total — not compounded
        return totalDiscount.add(strategy.apply(order.total).multiply(-1).add(order.total));
      },
      Money.zero(order.total.currency),
    );
  }
}

The Domain Service is injected into the Use Case (via DI from Part 9) and called before order.place():

TYPESCRIPT
// src/application/order/PlaceOrderUseCase.ts
export class PlaceOrderUseCase {
  constructor(
    private readonly orders: IOrderRepository,
    private readonly customers: ICustomerRepository,
    private readonly discountService: DiscountCalculationService,
    private readonly discountStrategies: IDiscountStrategy[],
    private readonly eventBus: IEventBus,
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderId> {
    const customer = await this.customers.findById(command.customerId);
    if (!customer) throw new DomainError(`Customer ${command.customerId} not found`);

    const order = Order.create(command.customerId);
    for (const item of command.items) order.addItem(item.productId, item.quantity, item.unitPrice);
    order.setShippingAddress(Address.create(command.shippingAddress));

    // Domain Service applied before placement — discount is applied to the order total
    const discount = this.discountService.bestDiscount(order, customer, this.discountStrategies);
    if (discount.amountCents > 0) order.applyDiscount(discount);

    order.place();
    await this.orders.save(order);

    const events = order.pullDomainEvents();
    for (const event of events) await this.eventBus.publish(event);

    return order.id;
  }
}

The Domain Service is stateless — it holds no instance state, only receives its inputs as parameters. DiscountCalculationService is registered as a singleton in the DI container; IDiscountStrategy[] is bound as a constant array of strategy instances.


3. The Specification Pattern

A Specification is a predicate — a single business rule expressed as an object with an isSatisfiedBy(candidate: T): boolean method. Specifications are:

  • Named: HasMinimumOrderAmount is self-documenting
  • Composable: specA.and(specB).or(specC).not() chains produce composite specifications
  • Testable in isolation: testing HasMinimumOrderAmount requires only a Money value, not an entire use case

3.1 The ISpecification<T> Interface

TYPESCRIPT
// src/domain/shared/ISpecification.ts
export interface ISpecification<T> {
  isSatisfiedBy(candidate: T): boolean;
  and(other: ISpecification<T>): ISpecification<T>;
  or(other: ISpecification<T>): ISpecification<T>;
  not(): ISpecification<T>;
}

3.2 The CompositeSpecification<T> Base Class

The base class implements and(), or(), and not() once — concrete specifications only need to implement isSatisfiedBy():

TYPESCRIPT
// src/domain/shared/CompositeSpecification.ts
import { ISpecification } from './ISpecification';

export abstract class CompositeSpecification<T> implements ISpecification<T> {
  abstract isSatisfiedBy(candidate: T): boolean;

  and(other: ISpecification<T>): ISpecification<T> {
    return new AndSpecification<T>(this, other);
  }

  or(other: ISpecification<T>): ISpecification<T> {
    return new OrSpecification<T>(this, other);
  }

  not(): ISpecification<T> {
    return new NotSpecification<T>(this);
  }
}

class AndSpecification<T> extends CompositeSpecification<T> {
  constructor(
    private readonly left: ISpecification<T>,
    private readonly right: ISpecification<T>,
  ) { super(); }

  isSatisfiedBy(candidate: T): boolean {
    return this.left.isSatisfiedBy(candidate) && this.right.isSatisfiedBy(candidate);
  }
}

class OrSpecification<T> extends CompositeSpecification<T> {
  constructor(
    private readonly left: ISpecification<T>,
    private readonly right: ISpecification<T>,
  ) { super(); }

  isSatisfiedBy(candidate: T): boolean {
    return this.left.isSatisfiedBy(candidate) || this.right.isSatisfiedBy(candidate);
  }
}

class NotSpecification<T> extends CompositeSpecification<T> {
  constructor(private readonly inner: ISpecification<T>) { super(); }

  isSatisfiedBy(candidate: T): boolean {
    return !this.inner.isSatisfiedBy(candidate);
  }
}

3.3 Concrete Specifications for the Discount Engine

Each specification is a named, self-contained, testable rule:

TYPESCRIPT
// src/domain/discount/specifications/HasMinimumOrderAmount.ts
import { CompositeSpecification } from '../../shared/CompositeSpecification';
import { Order } from '../../order/Order';
import { Money } from '../../payment/Money';

export class HasMinimumOrderAmount extends CompositeSpecification<Order> {
  constructor(private readonly threshold: Money) { super(); }

  isSatisfiedBy(order: Order): boolean {
    return order.total.greaterThan(this.threshold) || order.total.equals(this.threshold);
  }
}
TYPESCRIPT
// src/domain/discount/specifications/IsLoyalCustomer.ts
import { CompositeSpecification } from '../../shared/CompositeSpecification';
import { CustomerDiscountContext } from '../CustomerDiscountContext';

/** Context object carrying cross-Aggregate data needed for specifications */
export interface CustomerDiscountContext {
  customer: Customer;
  lifetimeOrderCount: number; // Pre-loaded by the Use Case from the read model
}

export class IsLoyalCustomer extends CompositeSpecification<CustomerDiscountContext> {
  constructor(private readonly minLifetimeOrders: number = 5) { super(); }

  isSatisfiedBy(ctx: CustomerDiscountContext): boolean {
    return ctx.lifetimeOrderCount >= this.minLifetimeOrders;
  }
}
TYPESCRIPT
// src/domain/discount/specifications/HasActiveSubscription.ts
import { CompositeSpecification } from '../../shared/CompositeSpecification';
import { CustomerDiscountContext } from '../CustomerDiscountContext';
import { SubscriptionPlan } from '../../subscription/SubscriptionPlan';

export class HasActiveSubscription extends CompositeSpecification<CustomerDiscountContext> {
  constructor(private readonly plan: SubscriptionPlan = SubscriptionPlan.ANY) { super(); }

  isSatisfiedBy(ctx: CustomerDiscountContext): boolean {
    return ctx.customer.activeSubscriptions.some(s =>
      this.plan === SubscriptionPlan.ANY || s.plan === this.plan
    );
  }
}
TYPESCRIPT
// src/domain/discount/specifications/IsVIPCustomer.ts
import { CompositeSpecification } from '../../shared/CompositeSpecification';
import { CustomerDiscountContext } from '../CustomerDiscountContext';

export class IsVIPCustomer extends CompositeSpecification<CustomerDiscountContext> {
  isSatisfiedBy(ctx: CustomerDiscountContext): boolean {
    return ctx.customer.tier === CustomerTier.VIP;
  }
}

3.4 Composing Specifications: Readable Business Rules

TYPESCRIPT
// src/domain/discount/EligibilityRules.ts
import { HasMinimumOrderAmount } from './specifications/HasMinimumOrderAmount';
import { IsLoyalCustomer } from './specifications/IsLoyalCustomer';
import { HasActiveSubscription } from './specifications/HasActiveSubscription';
import { IsVIPCustomer } from './specifications/IsVIPCustomer';
import { Money } from '../payment/Money';

/**
 * Loyalty discount eligibility:
 * Customer must have ≥5 lifetime orders AND an active subscription,
 * OR must be a VIP customer.
 *
 * This reads like the product spec — no branching, no mutation.
 */
export const loyaltyDiscountEligibility =
  new IsLoyalCustomer(5)
    .and(new HasActiveSubscription())
    .or(new IsVIPCustomer());

/**
 * Bulk order discount:
 * Order total ≥ $300 AND customer is NOT on a free tier.
 */
export const bulkOrderDiscountEligibility =
  new HasMinimumOrderAmount(Money.of('USD', 300))
    .and(new IsVIPCustomer().not()); // VIPs get a separate, better discount — exclude them here

The specification reads identically to the business requirement written in the product spec. There are no nested if statements, no string comparisons, no magic numbers without names.


4. The IDiscountStrategy Interface: Connecting Specs to Amounts

The Specification determines whether a discount applies. The Strategy determines how much the discount is:

TYPESCRIPT
// src/domain/discount/IDiscountStrategy.ts
import { Order } from '../order/Order';
import { Customer } from '../customer/Customer';
import { Money } from '../payment/Money';
import { ISpecification } from '../shared/ISpecification';
import { CustomerDiscountContext } from './CustomerDiscountContext';

export interface IDiscountStrategy {
  /** Display name for logging and debugging */
  readonly name: string;
  /** Higher priority wins in "best discount" mode */
  readonly priority: number;

  isApplicable(order: Order, ctx: CustomerDiscountContext): boolean;
  apply(orderTotal: Money): Money;
}
TYPESCRIPT
// src/domain/discount/strategies/LoyaltyDiscountStrategy.ts
import { CompositeSpecification } from '../../shared/CompositeSpecification';
import { loyaltyDiscountEligibility } from '../EligibilityRules';
import { IDiscountStrategy } from '../IDiscountStrategy';

export class LoyaltyDiscountStrategy implements IDiscountStrategy {
  readonly name = 'LoyaltyDiscount';
  readonly priority = 80;

  isApplicable(order: Order, ctx: CustomerDiscountContext): boolean {
    return loyaltyDiscountEligibility.isSatisfiedBy(ctx);
  }

  apply(total: Money): Money {
    return total.multiply(0.88); // 12% off — returns discounted total
  }
}
TYPESCRIPT
// src/domain/discount/strategies/VIPDiscountStrategy.ts
export class VIPDiscountStrategy implements IDiscountStrategy {
  readonly name = 'VIPDiscount';
  readonly priority = 100; // Always beats loyalty if both apply

  isApplicable(order: Order, ctx: CustomerDiscountContext): boolean {
    return new IsVIPCustomer().isSatisfiedBy(ctx);
  }

  apply(total: Money): Money {
    return total.multiply(0.80); // 20% off
  }
}
TYPESCRIPT
// src/domain/discount/strategies/BulkOrderDiscountStrategy.ts
export class BulkOrderDiscountStrategy implements IDiscountStrategy {
  readonly name = 'BulkOrderDiscount';
  readonly priority = 60;

  isApplicable(order: Order, ctx: CustomerDiscountContext): boolean {
    return bulkOrderDiscountEligibility.isSatisfiedBy(ctx); // Uses the composed specification
  }

  apply(total: Money): Money {
    return total.multiply(0.90); // 10% off
  }
}

Adding Black Friday: create BlackFridayDiscountStrategy with priority = 120. Register it in the DI container for the promotional period. Zero existing strategies modified. Remove the binding after Black Friday — production behaviour restored immediately.


5. Testing Specifications in Isolation

Each specification is a pure function of its inputs — no database, no HTTP, no dependencies:

TYPESCRIPT
// tests/unit/domain/discount/specifications.test.ts
import { HasMinimumOrderAmount } from '../../../../src/domain/discount/specifications/HasMinimumOrderAmount';
import { IsLoyalCustomer } from '../../../../src/domain/discount/specifications/IsLoyalCustomer';
import { loyaltyDiscountEligibility } from '../../../../src/domain/discount/EligibilityRules';
import { Money } from '../../../../src/domain/payment/Money';

describe('HasMinimumOrderAmount', () => {
  const spec = new HasMinimumOrderAmount(Money.of('USD', 100));

  it('should be satisfied when order total equals threshold', () => {
    const order = buildOrderWithTotal(Money.of('USD', 100));
    expect(spec.isSatisfiedBy(order)).toBe(true);
  });

  it('should be satisfied when order total exceeds threshold', () => {
    const order = buildOrderWithTotal(Money.of('USD', 250));
    expect(spec.isSatisfiedBy(order)).toBe(true);
  });

  it('should not be satisfied when order total is below threshold', () => {
    const order = buildOrderWithTotal(Money.of('USD', 50));
    expect(spec.isSatisfiedBy(order)).toBe(false);
  });
});

describe('loyaltyDiscountEligibility (composite)', () => {
  it('should be satisfied for VIP customer regardless of order count', () => {
    const ctx = buildContext({ tier: CustomerTier.VIP, lifetimeOrders: 0, hasActiveSubscription: false });
    expect(loyaltyDiscountEligibility.isSatisfiedBy(ctx)).toBe(true);
  });

  it('should be satisfied for loyal + subscribed non-VIP customer', () => {
    const ctx = buildContext({ tier: CustomerTier.STANDARD, lifetimeOrders: 7, hasActiveSubscription: true });
    expect(loyaltyDiscountEligibility.isSatisfiedBy(ctx)).toBe(true);
  });

  it('should not be satisfied for loyal customer without subscription', () => {
    const ctx = buildContext({ tier: CustomerTier.STANDARD, lifetimeOrders: 10, hasActiveSubscription: false });
    expect(loyaltyDiscountEligibility.isSatisfiedBy(ctx)).toBe(false);
  });

  it('should not be satisfied for subscribed customer with insufficient order history', () => {
    const ctx = buildContext({ tier: CustomerTier.STANDARD, lifetimeOrders: 3, hasActiveSubscription: true });
    expect(loyaltyDiscountEligibility.isSatisfiedBy(ctx)).toBe(false);
  });
});

Each test is 3 lines. No setup, no teardown, no mocking. The composite specification tree is exercised by testing the leaf specifications individually and the root composition with the four boundary cases that cover all branches.


6. Domain Service vs. Application Service: The Boundary

Concern Domain Service Application Service (Use Case)
Contains business rules ✅ Yes — rules that span Aggregates ❌ No — only orchestration
Stateless ✅ Required ✅ Required
Depends on repositories ❌ No (passed inputs, not loaders) ✅ Yes — loads Aggregates
Returns domain objects ✅ Yes (Money, DiscountResult) ✅ Yes, or view models
Calls event bus ❌ No ✅ Yes (after commit)
Layer location src/domain/discount/ src/application/order/

The DiscountCalculationService never calls a repository — the Use Case loads the Customer and passes it as a parameter. If the Domain Service required a repository to load data, it would violate the Domain layer's isolation. Data loading belongs in the Application layer.


Summary

Concept Domain Rule
Domain Service Stateless logic that spans Aggregate boundaries; receives domain objects as inputs, returns domain objects
Specification Named predicate object: isSatisfiedBy(T): boolean; composable via .and(), .or(), .not()
Composite Specification Base class implements composition operators; concrete specs only implement isSatisfiedBy()
Discount Strategy Determines amount; isApplicable() delegates to a Specification; apply() returns discounted Money
OCP compliance Adding a new discount: create new Specification + new Strategy + one DI binding — zero existing files modified
Testability Specifications are pure predicates; 3-line unit tests with no infrastructure setup

What's Next

In Part 11, we implement the full CQRS split and the Event Sourcing model — building a dedicated read model for order summaries that is continuously updated by the domain event stream, eliminating cross-Aggregate joins from read queries entirely. Part 11: CQRS & Event Sourcing →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Domain Services#Specification Pattern#Domain-Driven Design#TypeScript#Node.js#OOP#Business Rules
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.