Policy Pattern in DDD

How I use the Policy pattern to keep variable business rules out of aggregates, with pricing, discount, and shipping examples in TypeScript.

It was a Wednesday afternoon at the digital product agency I led engineering at, and we were three weeks into rewriting the pricing service for a boutique fitness product we ran. The Order aggregate had something like fourteen if branches about city, time of day, membership tier, and partner promos. A junior on the squad asked, totally reasonably, “is this still domain code or did we just move the spaghetti.” Yeah. Fair question.

That afternoon is the reason I’m careful about where business rules live. The short version, when an aggregate has more branches than fields, the rules want to be Policies. Not strategies-by-another-name, not a PricingDomainService with a switch, but actual named, swappable Policy objects with explicit conflict resolution and a domain event when they fire.

When rules outgrow the entity

The smell I trust is simple. When the aggregate has more conditional branches about external rules than it has fields about itself, the rules don’t belong inside it. They belong next to it.

I learned this during the portfolio-wide DDD migration I drove at that agency, pulling domain logic out of fat services across dozens of legacy client projects. Pricing was the worst offender almost every time. The aggregate was the first place rules landed, and the last place anyone wanted to delete from. Policies gave us a place to send them.

A pricing policy in TypeScript

Here’s the seam. The aggregate doesn’t know how to price itself, it just knows it has a price and that something computes it.

import { Money } from "../shared/money";
import { Order } from "../order/order.entity";
import { Customer } from "../customer/customer.entity";

export interface PricingPolicy {
  readonly name: string;
  apply(order: Order, customer: Customer): Money;
}

export class StandardPricingPolicy implements PricingPolicy {
  readonly name = "standard";

  apply(order: Order, _customer: Customer): Money {
    return order.lines.reduce(
      (sum, line) => sum.add(line.unitPrice.multiply(line.quantity)),
      Money.zero(order.currency),
    );
  }
}

export class MembershipTierPricingPolicy implements PricingPolicy {
  readonly name = "membership-tier";

  constructor(private readonly tierDiscounts: Record<string, number>) {}

  apply(order: Order, customer: Customer): Money {
    const base = order.lines.reduce(
      (sum, line) => sum.add(line.unitPrice.multiply(line.quantity)),
      Money.zero(order.currency),
    );
    const factor = this.tierDiscounts[customer.tier] ?? 0;
    if (factor <= 0) return base;
    return base.multiply(1 - factor);
  }
}

Two things to notice. The aggregate is not in here doing arithmetic about itself, and the policy doesn’t reach into infrastructure. It takes domain objects and returns a domain object. The seam is honest.

The application service does the wiring, which is the boring but important part. It picks a policy and calls apply. The aggregate gets handed a price, it doesn’t compute one.

Composition over branching

You won’t get away with one policy per concern for long. Coupons, loyalty, bulk discounts, partner promos, flash sales, they all want to fire on the same order. The temptation is to put them inside MembershipTierPricingPolicy with a few more if branches. Don’t.

Compose them.

export interface DiscountPolicy {
  readonly name: string;
  readonly priority: number;
  readonly exclusiveGroup?: string;
  apply(input: PriceContext): DiscountResult;
}

export interface PriceContext {
  readonly order: Order;
  readonly customer: Customer;
  readonly runningTotal: Money;
}

export interface DiscountResult {
  readonly amount: Money;
  readonly appliedBy: string;
}

export class CompositeDiscountPolicy implements DiscountPolicy {
  readonly name = "composite";
  readonly priority = 0;

  constructor(private readonly children: DiscountPolicy[]) {}

  apply(input: PriceContext): DiscountResult {
    const sorted = [...this.children].sort((a, b) => b.priority - a.priority);
    const usedGroups = new Set<string>();
    let total = Money.zero(input.runningTotal.currency);
    const trail: string[] = [];

    for (const child of sorted) {
      if (child.exclusiveGroup && usedGroups.has(child.exclusiveGroup)) continue;
      const r = child.apply({ ...input, runningTotal: input.runningTotal.subtract(total) });
      if (r.amount.isZero()) continue;
      total = total.add(r.amount);
      trail.push(r.appliedBy);
      if (child.exclusiveGroup) usedGroups.add(child.exclusiveGroup);
    }

    return { amount: total, appliedBy: trail.join("+") };
  }
}

Priority decides order. Exclusive groups make sure two flash-sale discounts don’t both fire when only one is supposed to. Each child sees the running total after earlier children, so a loyalty multiplier applies to a coupon-reduced price, not the original. The composite is itself a DiscountPolicy, so you can nest it if you really want, and most of the time you don’t.

I’m not a fan of inheritance for this. Subclassing BasePricingPolicy to override one method always ends with someone reading three files to figure out which one wins. Composition keeps it on one screen.

Runtime selection from configuration

Policies are not just for branching, they’re for swapping. Per tenant, per country, per feature flag, per A/B cell. The resolver is where this lives, and I like it explicit.

import { PrismaClient } from "@prisma/client";

export class PolicyRegistry {
  private readonly byName = new Map<string, PricingPolicy>();

  register(policy: PricingPolicy): void {
    this.byName.set(policy.name, policy);
  }

  get(name: string): PricingPolicy {
    const p = this.byName.get(name);
    if (!p) throw new Error(`unknown pricing policy: ${name}`);
    return p;
  }
}

export class PolicyResolver {
  constructor(
    private readonly prisma: PrismaClient,
    private readonly registry: PolicyRegistry,
  ) {}

  async resolveForTenant(tenantId: string): Promise<PricingPolicy> {
    const cfg = await this.prisma.tenantPricingConfig.findUnique({
      where: { tenantId },
      select: { policyName: true },
    });
    const name = cfg?.policyName ?? "standard";
    return this.registry.get(name);
  }
}

The hidden default is the thing that bites at 2 a.m. If tenantPricingConfig is missing, the resolver falls back to "standard". That’s a choice, not an accident, and I’d rather see it in code than discover it in a postmortem. We had a runbook line at the trading platform I architected that read, “If pricing looks wrong, check the resolver fallback first.” Boring, useful.

Conflict resolution between policies

This is the part most teams handwave. What happens when a loyalty discount and a flash-sale discount both want to fire. “We’ll figure it out later” is the answer right up until production figures it out for you.

For the runtime conflict itself, the rules I keep coming back to:

  • Priority is a number on the policy, not on the row. Code defines it, rows pick it.
  • Exclusive groups beat priority for “these can’t both fire.” A free-month coupon and a flash-sale share a group, only one wins.
  • Audit every fire. Which policies considered themselves applicable, which one won, what amount.

That last bullet is the one teams skip and regret.

Shipping policies that survive change

Once policies are real objects, they get their own lifecycle. They emit domain events when they fire. They get versioned. The Billing context needs to know which discount was applied and why, not just the final number.

import { Repository } from "typeorm";
import { OrderPricingApplied } from "./events";
import { DomainEventBus } from "../shared/events";

export class OrderPricingService {
  constructor(
    private readonly orders: Repository<Order>,
    private readonly resolver: PolicyResolver,
    private readonly discounts: CompositeDiscountPolicy,
    private readonly events: DomainEventBus,
  ) {}

  async price(orderId: string, customer: Customer): Promise<Money> {
    const order = await this.orders.findOneByOrFail({ id: orderId });
    const pricing = await this.resolver.resolveForTenant(order.tenantId);
    const base = pricing.apply(order, customer);
    const discount = this.discounts.apply({ order, customer, runningTotal: base });
    const final = base.subtract(discount.amount);

    order.applyPrice(final);
    await this.orders.save(order);

    await this.events.publish(
      new OrderPricingApplied({
        orderId: order.id,
        tenantId: order.tenantId,
        policyName: pricing.name,
        discountTrail: discount.appliedBy,
        finalAmount: final.toJSON(),
      }),
    );

    return final;
  }
}

The event is the contract with the rest of the system. Billing subscribes. Reporting subscribes. Customer-facing receipts subscribe.

Takeaways

  • If the aggregate has more if branches than fields, the rules want to be Policies.
  • Compose policies, do not inherit them. Priority and exclusive groups beat a class hierarchy.
  • Make the resolver explicit. Default fallbacks belong in code, not in folklore.
  • Conflict resolution is part of the policy, not a footnote.
  • Every policy fire is a domain event. Audit it, hand it to downstream contexts.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.