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.
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.
The billing engine's discount rules at month three of a real project:
export class Order extends AggregateRoot<OrderId> {
calculateDiscount(customer: Customer, allOrders: Order[]): Money {
if (customer.tier === 'VIP') return this.total.multiply(0.20);
if (this.total.greaterThan(Money.of('USD', 500))) return this.total.multiply(0.10);
const lifetimeOrders = allOrders.filter(o => o.customerId === customer.id);
if (lifetimeOrders.length >= 5) return this.total.multiply(0.08);
if (customer.subscriptions.some(s => s.plan === 'PREMIUM' && s.isActive())) {
return this.total.multiply(0.12);
}
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:
Order depends on Customer — but these are separate Aggregates. Order.calculateDiscount(customer) means the Order must receive and inspect another Aggregate's internal state.
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.
- Hardcoded Black Friday date — domain logic that depends on wall-clock time cannot be unit-tested deterministically.
- 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.
- Adding Rule 6 requires modifying
Order — every new discount rule is a change to the most critical class in the domain model.
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.
import { Order } from '../order/Order';
import { Customer } from '../customer/Customer';
import { Money } from '../payment/Money';
import { IDiscountStrategy } from './IDiscountStrategy';
export class DiscountCalculationService {
bestDiscount(
order: Order,
customer: Customer,
strategies: IDiscountStrategy[],
): Money {
const applicable = strategies
.filter(strategy => strategy.isApplicable(order, customer))
.sort((a, b) => b.priority - a.priority);
return applicable.length > 0
? applicable[0].apply(order.total)
: Money.zero(order.total.currency);
}
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);
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():
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));
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.
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
export interface ISpecification<T> {
isSatisfiedBy(candidate: T): boolean;
and(other: ISpecification<T>): ISpecification<T>;
or(other: ISpecification<T>): ISpecification<T>;
not(): ISpecification<T>;
}
The base class implements and(), or(), and not() once — concrete specifications only need to implement isSatisfiedBy():
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);
}
}
Each specification is a named, self-contained, testable rule:
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);
}
}
import { CompositeSpecification } from '../../shared/CompositeSpecification';
import { CustomerDiscountContext } from '../CustomerDiscountContext';
export interface CustomerDiscountContext {
customer: Customer;
lifetimeOrderCount: number;
}
export class IsLoyalCustomer extends CompositeSpecification<CustomerDiscountContext> {
constructor(private readonly minLifetimeOrders: number = 5) { super(); }
isSatisfiedBy(ctx: CustomerDiscountContext): boolean {
return ctx.lifetimeOrderCount >= this.minLifetimeOrders;
}
}
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
);
}
}
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;
}
}
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';
export const loyaltyDiscountEligibility =
new IsLoyalCustomer(5)
.and(new HasActiveSubscription())
.or(new IsVIPCustomer());
export const bulkOrderDiscountEligibility =
new HasMinimumOrderAmount(Money.of('USD', 300))
.and(new IsVIPCustomer().not());
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.
The Specification determines whether a discount applies. The Strategy determines how much the discount is:
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 {
readonly name: string;
readonly priority: number;
isApplicable(order: Order, ctx: CustomerDiscountContext): boolean;
apply(orderTotal: Money): Money;
}
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);
}
}
export class VIPDiscountStrategy implements IDiscountStrategy {
readonly name = 'VIPDiscount';
readonly priority = 100;
isApplicable(order: Order, ctx: CustomerDiscountContext): boolean {
return new IsVIPCustomer().isSatisfiedBy(ctx);
}
apply(total: Money): Money {
return total.multiply(0.80);
}
}
export class BulkOrderDiscountStrategy implements IDiscountStrategy {
readonly name = 'BulkOrderDiscount';
readonly priority = 60;
isApplicable(order: Order, ctx: CustomerDiscountContext): boolean {
return bulkOrderDiscountEligibility.isSatisfiedBy(ctx);
}
apply(total: Money): Money {
return total.multiply(0.90);
}
}
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.
Each specification is a pure function of its inputs — no database, no HTTP, no dependencies:
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.
| 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.
| 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 |
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 →