Domains

Domain Modeling, Explained

A didactic, practical guide for everyday engineering

1) What is a “Domain”? (clear definition)

A domain is the problem space your software exists to serve—its concepts, rules, and processes in the language of the business.
Domain modeling is the craft of expressing that knowledge directly in code so changes in the business map cleanly to changes in the software.


2) Why it matters (practical relevance)


3) Core building blocks (the vocabulary)

Entities

Value Objects (VOs)

Aggregates

Domain Services

Policies (Domain Policies)

Domain Events

Errors (Domain Errors)

(Optional but useful) Repositories belong to the application/infrastructure layer and persist aggregates by root, not by internal entities.


4) How to apply it (step-by-step)

  1. Ubiquitous Language
    Collect 10–20 key terms with stakeholders. Write short definitions everyone agrees on.

  2. Bounded Contexts
    Partition the domain into cohesive subdomains where the language is consistent (e.g., Orders, Payments, Catalog). Avoid one model for everything.

  3. Capabilities → Use Cases
    For each context, list core capabilities and turn the most important into use cases (3–7 steps each).

  4. Identify Aggregates
    For each use case, ask: What must be consistent atomically? That set is one aggregate. Keep it small.

  5. Model Entities & VOs
    Give IDs to things with identity; make the rest immutable VOs with validations.

  6. Policies & Invariants
    Write the rules. Put local rules inside aggregates; cross-cutting rules in policies or domain services.

  7. Domain Events
    Emit past-tense facts from aggregate methods that change state.

  8. Errors
    Model expected business failures explicitly (typed errors), separate from technical failures.

  9. Contracts & Persistence
    Define ports (interfaces) for repositories and services. Keep infrastructure out of the domain.

  10. Tests


5) Practical examples & analogies

Example A — E-commerce Order

Example B — Subscription Billing

Analogy 1 — The Safe Box

An aggregate is like a safe. Many items inside (entities/VOs), but there’s one door (aggregate root) that enforces the rules before anything changes.

Analogy 2 — Kitchen Pass

Orders go through a kitchen pass. Chefs (entities) do the cooking, but the head chef (aggregate root) checks every plate for quality (invariants) before it leaves.


6) Common mistakes & how to avoid them


7) A short framework/checklist

The “7-Box” Domain Sketch

  1. Context Name

  2. Purpose/Outcome

  3. Ubiquitous Terms (≤10)

  4. Aggregates (with invariants)

  5. Value Objects (with validations)

  6. Policies/Domain Services (why separate?)

  7. Domain Events (name + minimal payload)

Aggregate Decision Tree


8) Actionable takeaways

  1. Start with language, not classes. Write a one-page glossary and context map first.

  2. Model invariants where they can’t be bypassed: inside aggregates/VOs.

  3. Keep aggregates small; one transaction = one root.

  4. Make rules explicit: encode policies and domain errors.

  5. Tell the story with events: emit past-tense facts for integrations and audit.


Closing

Domain modeling isn’t ceremony—it’s the shortest path between business intent and reliable code. By naming things well, drawing boundaries, and placing rules at the right level (VOs, aggregates, policies), you make change safe and fast. Start small, keep the center pure, and let infrastructure plug in at the edges.

Here are two complete, practical DDD domain examples in TypeScript, each showing Entities, Value Objects, Aggregates, Policies, Domain Services, Domain Events, Domain Errors, and Repository ports. They’re framework-agnostic and small enough to drop into any project.

Domain Example 1 — E-commerce Order

What this domain models

A customer builds a cart and places an order. The Order aggregate enforces invariants like “you can’t place an empty order,” “you can’t modify items after placement,” and “totals must match item sums and taxes.”

Folder sketch

ecommerce/
  order/
    domain/
      order.ts
      value-objects.ts
      policies.ts
      services.ts
      events.ts
      errors.ts
      ports.ts
    application/
      place-order.usecase.ts

value-objects.ts

// Value Objects are immutable and validated at construction
export class Money {
  constructor(readonly amount: number, readonly currency: 'USD'|'EUR'|'BRL') {
    if (!Number.isFinite(amount)) throw new Error('Invalid amount');
  }
  add(other: Money) {
    this.assertSame(other);
    return new Money(this.amount + other.amount, this.currency);
  }
  multiply(qty: number) {
    if (!Number.isInteger(qty) || qty <= 0) throw new Error('Invalid quantity');
    return new Money(this.amount * qty, this.currency);
  }
  private assertSame(other: Money) {
    if (other.currency !== this.currency) throw new Error('Currency mismatch');
  }
}

export class Sku {
  constructor(readonly value: string) {
    if (!value || value.trim().length < 2) throw new Error('Invalid SKU');
  }
}

export class Quantity {
  constructor(readonly value: number) {
    if (!Number.isInteger(value) || value <= 0) throw new Error('Invalid quantity');
  }
}

export class Address {
  constructor(
    readonly line1: string,
    readonly city: string,
    readonly country: string,
    readonly postalCode: string
  ) {
    if (!line1 || !city || !country || !postalCode) throw new Error('Invalid address');
  }
}

events.ts

export type DomainEvent =
  | OrderItemAdded
  | OrderPlaced
  | OrderCancelled;

export class OrderItemAdded {
  readonly type = 'OrderItemAdded';
  constructor(public orderId: string, public sku: string, public qty: number) {}
}

export class OrderPlaced {
  readonly type = 'OrderPlaced';
  constructor(public orderId: string, public total: number, public currency: string) {}
}

export class OrderCancelled {
  readonly type = 'OrderCancelled';
  constructor(public orderId: string, public reason: string) {}
}

errors.ts

export class EmptyOrderError extends Error { constructor(){ super('Order has no items'); } }
export class OrderAlreadyPlacedError extends Error { constructor(){ super('Order already placed'); } }
export class CannotModifyPlacedOrderError extends Error { constructor(){ super('Cannot modify after placement'); } }

policies.ts

import { Money } from './value-objects';

export interface PlacementPolicy {
  ensureCanPlace(total: Money): void;
}

export class MinOrderTotalPolicy implements PlacementPolicy {
  constructor(private readonly min: Money) {}
  ensureCanPlace(total: Money) {
    if (total.currency !== this.min.currency) throw new Error('Currency mismatch');
    if (total.amount < this.min.amount) throw new Error(`Minimum order total is ${this.min.amount} ${this.min.currency}`);
  }
}

services.ts (Domain Service for pricing/tax)

import { Money } from './value-objects';

export interface TaxService {
  // pure function from subtotal to tax (domain-facing abstraction)
  taxFor(subtotal: Money, destinationCountry: string): Money;
}

export class FlatTaxService implements TaxService {
  constructor(private readonly rate: number) {}
  taxFor(subtotal: Money): Money {
    return new Money(Math.round(subtotal.amount * this.rate), subtotal.currency);
  }
}

order.ts (Aggregate Root + internal entity)

import { Address, Money, Quantity, Sku } from './value-objects';
import { DomainEvent, OrderItemAdded, OrderPlaced, OrderCancelled } from './events';
import { EmptyOrderError, OrderAlreadyPlacedError, CannotModifyPlacedOrderError } from './errors';
import { PlacementPolicy } from './policies';
import { TaxService } from './services';

class OrderItem {
  constructor(readonly sku: Sku, readonly unitPrice: Money, readonly qty: Quantity) {}
  lineTotal(): Money { return this.unitPrice.multiply(this.qty.value); }
}

export class Order {
  private status: 'DRAFT'|'PLACED'|'CANCELLED' = 'DRAFT';
  private items: OrderItem[] = [];
  private events: DomainEvent[] = [];

  constructor(
    readonly id: string,
    private readonly currency: Money['currency'],
    private shippingAddress?: Address
  ) {}

  addItem(sku: Sku, price: Money, qty: Quantity) {
    if (this.status !== 'DRAFT') throw new CannotModifyPlacedOrderError();
    if (price.currency !== this.currency) throw new Error('Currency mismatch');
    this.items.push(new OrderItem(sku, price, qty));
    this.events.push(new OrderItemAdded(this.id, sku.value, qty.value));
  }

  setShipping(address: Address) {
    if (this.status !== 'DRAFT') throw new CannotModifyPlacedOrderError();
    this.shippingAddress = address;
  }

  subtotal(): Money {
    return this.items.reduce(
      (sum, it) => sum ? sum.add(it.lineTotal()) : it.lineTotal(),
      undefined as unknown as Money
    ) ?? new Money(0, this.currency);
  }

  place(policy: PlacementPolicy, taxes: TaxService) {
    if (this.status !== 'DRAFT') throw new OrderAlreadyPlacedError();
    if (this.items.length === 0) throw new EmptyOrderError();
    if (!this.shippingAddress) throw new Error('Missing shipping address');

    const sub = this.subtotal();
    policy.ensureCanPlace(sub);

    const tax = taxes.taxFor(sub, this.shippingAddress.country);
    const total = sub.add(tax);

    this.status = 'PLACED';
    this.events.push(new OrderPlaced(this.id, total.amount, total.currency));
    return { total, tax };
  }

  cancel(reason: string) {
    if (this.status !== 'PLACED') throw new Error('Only placed orders can be cancelled');
    this.status = 'CANCELLED';
    this.events.push(new OrderCancelled(this.id, reason));
  }

  pullEvents(): DomainEvent[] {
    const out = [...this.events];
    this.events = [];
    return out;
  }
}

ports.ts (Repository & external ports)

import { Order } from './order';

export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>; // persists root + internal items
}

place-order.usecase.ts (Application layer example)

import { OrderRepository } from '../domain/ports';
import { MinOrderTotalPolicy } from '../domain/policies';
import { FlatTaxService } from '../domain/services';
import { Money, Address, Sku, Quantity } from '../domain/value-objects';
import { Order } from '../domain/order';

export class PlaceOrder {
  constructor(private readonly repo: OrderRepository) {}

  async exec(input: {
    orderId: string;
    items: Array<{ sku: string; price: number; qty: number; currency: 'USD'|'EUR'|'BRL' }>;
    shipping: { line1: string; city: string; country: string; postalCode: string };
  }) {
    const order = new Order(input.orderId, input.items[0].currency);
    order.setShipping(new Address(input.shipping.line1, input.shipping.city, input.shipping.country, input.shipping.postalCode));

    for (const it of input.items) {
      order.addItem(new Sku(it.sku), new Money(it.price, it.currency), new Quantity(it.qty));
    }

    const policy = new MinOrderTotalPolicy(new Money(1000, input.items[0].currency)); // e.g., min $10.00 if using cents
    const taxes = new FlatTaxService(0.08);

    const { total, tax } = order.place(policy, taxes);

    await this.repo.save(order);
    const events = order.pullEvents();
    return { total, tax, events };
  }
}

What to notice


Domain Example 2 — Subscription Billing

What this domain models

A customer has a Subscription to a plan with a billing cycle. The aggregate enforces invariants like “can only renew within the renewal window,” “cancelling stops future renewals,” and “plan changes follow policy (e.g., proration).”

Folder sketch

billing/
  subscription/
    domain/
      subscription.ts
      value-objects.ts
      policies.ts
      services.ts
      events.ts
      errors.ts
      ports.ts
    application/
      renew-subscription.usecase.ts

value-objects.ts

export class Money {
  constructor(readonly amount: number, readonly currency: 'USD'|'EUR'|'BRL') {
    if (!Number.isFinite(amount)) throw new Error('Invalid amount');
  }
  add(other: Money) {
    if (other.currency !== this.currency) throw new Error('Currency mismatch');
    return new Money(this.amount + other.amount, this.currency);
  }
}

export class PlanId {
  constructor(readonly value: string) {
    if (!value) throw new Error('Invalid PlanId');
  }
}

export class Period {
  constructor(readonly start: Date, readonly end: Date) {
    if (end <= start) throw new Error('Invalid period');
  }
  contains(d: Date) { return d >= this.start && d <= this.end; }
  next(days: number) {
    const start = new Date(this.end.getTime());
    const end = new Date(start.getTime() + days*24*60*60*1000);
    return new Period(start, end);
  }
}

events.ts

export type DomainEvent =
  | SubscriptionActivated
  | SubscriptionRenewed
  | SubscriptionCancelled
  | PlanChanged;

export class SubscriptionActivated {
  readonly type = 'SubscriptionActivated';
  constructor(public subscriptionId: string, public planId: string) {}
}
export class SubscriptionRenewed {
  readonly type = 'SubscriptionRenewed';
  constructor(public subscriptionId: string, public periodEnd: string, public amount: number, public currency: string) {}
}
export class SubscriptionCancelled {
  readonly type = 'SubscriptionCancelled';
  constructor(public subscriptionId: string) {}
}
export class PlanChanged {
  readonly type = 'PlanChanged';
  constructor(public subscriptionId: string, public newPlanId: string) {}
}

errors.ts

export class AlreadyCancelledError extends Error { constructor(){ super('Subscription already cancelled'); } }
export class RenewalTooEarlyError extends Error { constructor(){ super('Cannot renew before current period ends'); } }
export class InvalidPlanChangeError extends Error { constructor(){ super('Plan change not allowed by policy'); } }

policies.ts

import { PlanId } from './value-objects';

export interface PlanChangePolicy {
  ensureAllowed(current: PlanId, next: PlanId): void;
}

export class UpgradeOnlyPolicy implements PlanChangePolicy {
  // simplistic: IDs encode tier ordering (e.g., "basic" < "pro" < "enterprise")
  private order = new Map([['basic', 1], ['pro', 2], ['enterprise', 3]]);
  ensureAllowed(current: PlanId, next: PlanId) {
    if ((this.order.get(next.value) ?? 0) < (this.order.get(current.value) ?? 0)) {
      throw new Error('Downgrades are not allowed mid-cycle');
    }
  }
}

services.ts (Domain Service for proration)

import { Money, Period } from './value-objects';

export interface ProrationService {
  prorate(fullPrice: Money, currentPeriod: Period, changeDate: Date): Money;
}

export class SimpleDailyProration implements ProrationService {
  prorate(fullPrice: Money, current: Period, changeDate: Date): Money {
    const totalDays = Math.ceil((current.end.getTime() - current.start.getTime()) / 86400000);
    const remainingDays = Math.max(0, Math.ceil((current.end.getTime() - changeDate.getTime()) / 86400000));
    const fraction = remainingDays / totalDays;
    return new Money(Math.round(fullPrice.amount * fraction), fullPrice.currency);
  }
}

subscription.ts (Aggregate Root)

import { Money, Period, PlanId } from './value-objects';
import { DomainEvent, SubscriptionActivated, SubscriptionRenewed, SubscriptionCancelled, PlanChanged } from './events';
import { AlreadyCancelledError, RenewalTooEarlyError, InvalidPlanChangeError } from './errors';
import { PlanChangePolicy } from './policies';
import { ProrationService } from './services';

export class Subscription {
  private status: 'ACTIVE'|'CANCELLED' = 'ACTIVE';
  private events: DomainEvent[] = [];

  constructor(
    readonly id: string,
    private plan: PlanId,
    private price: Money,
    private period: Period
  ) {
    this.events.push(new SubscriptionActivated(this.id, this.plan.value));
  }

  renew(now: Date) {
    if (this.status === 'CANCELLED') throw new AlreadyCancelledError();
    if (now < this.period.end) throw new RenewalTooEarlyError();

    // extend by 30 days (example)
    this.period = this.period.next(30);
    this.events.push(new SubscriptionRenewed(this.id, this.period.end.toISOString(), this.price.amount, this.price.currency));
  }

  changePlan(newPlan: PlanId, fullPrice: Money, policy: PlanChangePolicy, proration: ProrationService, now: Date) {
    if (this.status === 'CANCELLED') throw new AlreadyCancelledError();

    policy.ensureAllowed(this.plan, newPlan);
    const credit = proration.prorate(this.price, this.period, now); // credit from remaining days

    // new price takes effect immediately (example). Billing system can apply credit.
    this.plan = newPlan;
    this.price = fullPrice;

    this.events.push(new PlanChanged(this.id, newPlan.value));
    // You might also raise a Billing event like "ProrationCreditGranted"
  }

  cancel() {
    if (this.status === 'CANCELLED') throw new AlreadyCancelledError();
    this.status = 'CANCELLED';
    this.events.push(new SubscriptionCancelled(this.id));
  }

  pullEvents(): DomainEvent[] {
    const out = [...this.events];
    this.events = [];
    return out;
  }

  snapshot() {
    return { id: this.id, plan: this.plan.value, price: this.price.amount, currency: this.price.currency, period: this.period };
  }
}

ports.ts

import { Subscription } from './subscription';

export interface SubscriptionRepository {
  findById(id: string): Promise<Subscription|null>;
  save(sub: Subscription): Promise<void>;
}

renew-subscription.usecase.ts (Application layer example)

import { SubscriptionRepository } from '../domain/ports';

export class RenewSubscription {
  constructor(private readonly repo: SubscriptionRepository) {}

  async exec(input: { subscriptionId: string; now?: Date }) {
    const sub = await this.repo.findById(input.subscriptionId);
    if (!sub) throw new Error('Not found');

    sub.renew(input.now ?? new Date());

    await this.repo.save(sub);
    return { events: sub.pullEvents(), snapshot: sub.snapshot() };
  }
}

What to notice


How to use these examples

If you want, I can package both into a ready-to-run Nx workspace skeleton with tests and minimal adapters so your team can clone and start coding on top.