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)
-
Faster change: When rules live in the right place, new requirements mean small, local code changes.
-
Fewer bugs: Business invariants are enforced where they cannot be bypassed.
-
Shared understanding: Product, design, and engineering speak the same Ubiquitous Language, reducing misinterpretations.
-
Longevity: A good domain model outlives frameworks and tech stacks.
3) Core building blocks (the vocabulary)
Entities
-
What: Objects with identity that persists as attributes change (e.g.,
Order,Customer). -
When to use: You care who it is over time (ID), not just what it looks like today.
Value Objects (VOs)
-
What: Immutable objects defined by their values (e.g.,
Money,Email,TimeWindow). -
Why: Capture meaning + invariants (currency matches, email format), ease testing and reuse.
Aggregates
-
What: A consistency boundary: a root entity plus its internal graph (entities/VOs) treated as one unit for changes.
-
Rules:
-
Only the Aggregate Root is referenced from the outside.
-
One transaction updates one aggregate.
-
Aggregates stay small and enforce invariants.
-
Domain Services
-
What: Stateless operations that belong to the domain but don’t naturally live in a single entity (e.g., pricing calculation across several aggregates).
-
Use: When behavior spans multiple roots or depends on external policies.
Policies (Domain Policies)
-
What: Business rules/constraints that guide decisions (e.g., “Only DRAFT orders can be placed”, “Max leverage = 5x”).
-
How: Encapsulate them as code, often injected into aggregates/services to keep rules explicit and testable.
Domain Events
-
What: Past-tense facts about things that happened in the domain (e.g.,
OrderPlaced,PaymentCaptured). -
Why: Make side effects explicit, enable integrations and projections, and create an audit trail.
Errors (Domain Errors)
-
What: Expected, business-level failures (e.g.,
InsufficientFunds,OrderAlreadyShipped). -
How: Represent as typed errors or result types; keep them distinct from technical errors (e.g., DB timeouts).
(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)
-
Ubiquitous Language
Collect 10–20 key terms with stakeholders. Write short definitions everyone agrees on. -
Bounded Contexts
Partition the domain into cohesive subdomains where the language is consistent (e.g., Orders, Payments, Catalog). Avoid one model for everything. -
Capabilities → Use Cases
For each context, list core capabilities and turn the most important into use cases (3–7 steps each). -
Identify Aggregates
For each use case, ask: What must be consistent atomically? That set is one aggregate. Keep it small. -
Model Entities & VOs
Give IDs to things with identity; make the rest immutable VOs with validations. -
Policies & Invariants
Write the rules. Put local rules inside aggregates; cross-cutting rules in policies or domain services. -
Domain Events
Emit past-tense facts from aggregate methods that change state. -
Errors
Model expected business failures explicitly (typed errors), separate from technical failures. -
Contracts & Persistence
Define ports (interfaces) for repositories and services. Keep infrastructure out of the domain. -
Tests
-
Unit-test invariants on entities/aggregates.
-
Scenario-test domain services/policies.
-
Contract-test events and application ports.
5) Practical examples & analogies
Example A — E-commerce Order
-
Aggregate Root:
Orderwith internalOrderItems and VOMoney. -
Invariants:
-
Cannot add items after
status=PLACED. -
total == sum(items.price * qty).
-
-
Policy:
PlacementPolicy(e.g., credit check must pass). -
Events:
OrderPlaced,OrderCancelled. -
Domain Error:
EmptyOrder,OrderAlreadyPlaced.
Example B — Subscription Billing
-
Aggregate Root:
Subscription(plan, status, period). -
VOs:
BillingCycle,Price,TimeWindow. -
Policy:
RenewalPolicy(grace periods, proration). -
Events:
SubscriptionActivated,SubscriptionRenewed,SubscriptionExpired. -
Domain Error:
PlanNotCompatible,RenewalTooEarly.
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
-
Anemic Domain Model: Only getters/setters; rules in services or controllers.
→ Fix: Put invariants and behavior inside aggregates/VOs. -
Giant Aggregates: Everything in one root; locks and contention.
→ Fix: Split by atomic invariants and lifecycle. Keep roots small. -
Repo per Child Entity: Bypasses the root and breaks invariants.
→ Fix: One repository per aggregate root. -
Mixing Domain & Technical Errors: Confusing business failures with infrastructure issues.
→ Fix: SeparateDomainError(expected) fromSystemError(unexpected). -
Leaky Boundaries: Sharing DB tables across contexts.
→ Fix: Integrate via events/APIs, not shared schemas. -
Mutable VOs: Value objects that change internally.
→ Fix: VOs are immutable; return new instances on change.
7) A short framework/checklist
The “7-Box” Domain Sketch
-
Context Name
-
Purpose/Outcome
-
Ubiquitous Terms (≤10)
-
Aggregates (with invariants)
-
Value Objects (with validations)
-
Policies/Domain Services (why separate?)
-
Domain Events (name + minimal payload)
Aggregate Decision Tree
-
Must change together atomically? → Same aggregate.
-
Different lifecycles/high write volume? → Separate aggregates or read models.
-
Access from outside? → Expose via root only, reference others by ID.
8) Actionable takeaways
-
Start with language, not classes. Write a one-page glossary and context map first.
-
Model invariants where they can’t be bypassed: inside aggregates/VOs.
-
Keep aggregates small; one transaction = one root.
-
Make rules explicit: encode policies and domain errors.
-
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
-
Aggregate Root
Ordercontrols all mutations and emits Domain Events. -
Policies and Domain Services are injected → easy to change and test.
-
Domain Errors are explicit and separate from infra errors.
-
Repository is per aggregate root, not per child entity.
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
-
Aggregate Root
Subscriptionenforces renewal and cancellation rules. -
Plan changes are guarded by a Policy and compute credits via a Domain Service.
-
Events capture important state transitions for billing and analytics.
-
Domain Errors are explicit (business failures) and separate from infra failures.
How to use these examples
-
Drop the
domain/folders into your project to serve as golden references. -
Add contract tests for events (JSON schema) at the boundaries.
-
Implement repositories in your infrastructure layer (ORM/DB), keeping the domain pure.
-
Wire application use cases behind HTTP/gRPC handlers or message consumers.
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.