Domain-Driven Design (DDD) Cheat Sheet
Covers core DDD building blocks — entities, value objects, aggregates, repositories, domain events — plus bounded contexts and the ubiquitous language.
Entities vs Value Objects
Entities have identity that persists through change; value objects are defined entirely by their attributes.
// Entity: identity matters, attributes can changeclass Order { constructor( readonly id: OrderId, // identity — never changes private items: OrderLine[], private status: OrderStatus, ) {} addLine(line: OrderLine) { this.items.push(line); }}// Value Object: no identity, immutable, equality by valueclass Money { constructor(readonly amount: number, readonly currency: string) {} equals(other: Money): boolean { return this.amount === other.amount && this.currency === other.currency; } add(other: Money): Money { if (other.currency !== this.currency) throw new Error('currency mismatch'); return new Money(this.amount + other.amount, this.currency); }}
Aggregates & Repositories
An aggregate is a consistency boundary with one root entity; repositories load/persist whole aggregates.
// Aggregate root: the only entry point for mutating the aggregateclass ShoppingCart { private constructor( readonly id: CartId, private lines: CartLine[], ) {} static create(id: CartId): ShoppingCart { return new ShoppingCart(id, []); } addItem(productId: ProductId, qty: number): void { if (qty <= 0) throw new Error('quantity must be positive'); // invariant this.lines.push(new CartLine(productId, qty)); }}interface CartRepository { findById(id: CartId): Promise<ShoppingCart | null>; save(cart: ShoppingCart): Promise<void>;}
Domain Events
Model significant business occurrences explicitly, decoupling side effects from the aggregate that raised them.
class OrderPlaced { readonly occurredAt = new Date(); constructor(readonly orderId: OrderId, readonly total: Money) {}}class Order { private events: DomainEvent[] = []; place(): void { this.status = OrderStatus.Placed; this.events.push(new OrderPlaced(this.id, this.total)); } pullEvents(): DomainEvent[] { const events = this.events; this.events = []; return events; }}// Application layer publishes events after the transaction commitsconst events = order.pullEvents();events.forEach(e => eventBus.publish(e));
DDD Glossary
Core vocabulary from Eric Evans' book and the community that followed.
- Ubiquitous Language- shared vocabulary between developers and domain experts, used in code and conversation
- Bounded Context- an explicit boundary within which a model and its language are consistent
- Entity- object defined by continuity of identity, not attributes
- Value Object- object defined by its attributes, immutable, no identity
- Aggregate / Aggregate Root- cluster of objects treated as one consistency/transaction boundary
- Repository- abstraction for loading/persisting whole aggregates, hides storage details
- Domain Event- immutable record of something significant that happened in the domain
- Context Mapping- explicit patterns (shared kernel, anti-corruption layer, etc.) for context relationships
Anti-Corruption Layer at a Context Boundary
Translate an external/legacy model into your bounded context's ubiquitous language so upstream changes never leak into your domain.
// Legacy CRM shape — outside our control, uses its own vocabularyinterface LegacyCrmCustomer { cust_id: string; full_nm: string; acct_status_cd: 'A' | 'S' | 'C';}// Our domain's ubiquitous languageclass Customer { private constructor( readonly id: CustomerId, readonly name: string, readonly status: CustomerStatus, ) {} static fromLegacy(raw: LegacyCrmCustomer): Customer { const statusMap: Record<string, CustomerStatus> = { A: CustomerStatus.Active, S: CustomerStatus.Suspended, C: CustomerStatus.Closed, }; return new Customer( new CustomerId(raw.cust_id), raw.full_nm, statusMap[raw.acct_status_cd], ); }}// The ACL is the only place that ever imports LegacyCrmCustomerclass CrmAntiCorruptionLayer { constructor(private crmClient: LegacyCrmClient) {} async getCustomer(id: CustomerId): Promise<Customer> { const raw = await this.crmClient.fetch(id.value); return Customer.fromLegacy(raw); }}
Specification Pattern for Composable Business Rules
Encapsulate a business predicate as an object so complex eligibility/invariant rules can be composed, tested, and reused independently of where they're checked.
interface Specification<T> { isSatisfiedBy(candidate: T): boolean; and(other: Specification<T>): Specification<T>;}abstract class BaseSpec<T> implements Specification<T> { abstract isSatisfiedBy(candidate: T): boolean; and(other: Specification<T>): Specification<T> { return new AndSpec(this, other); }}class AndSpec<T> extends BaseSpec<T> { constructor(private left: Specification<T>, private right: Specification<T>) { super(); } isSatisfiedBy(candidate: T): boolean { return this.left.isSatisfiedBy(candidate) && this.right.isSatisfiedBy(candidate); }}class IsPremiumCustomer extends BaseSpec<Customer> { isSatisfiedBy(c: Customer): boolean { return c.tier === 'premium'; }}class HasNoOpenDisputes extends BaseSpec<Customer> { isSatisfiedBy(c: Customer): boolean { return c.openDisputes === 0; }}const eligibleForCreditLine = new IsPremiumCustomer().and(new HasNoOpenDisputes());if (eligibleForCreditLine.isSatisfiedBy(customer)) { /* approve */ }
Domain Service vs. Application Service
A domain service holds stateless domain logic that doesn't fit naturally on one entity; an application service orchestrates use cases, transactions, and infrastructure.
// Domain service: pure business logic spanning multiple aggregates,// no I/O, no framework dependenciesclass FundsTransferService { transfer(from: Account, to: Account, amount: Money): void { if (!from.canWithdraw(amount)) { throw new InsufficientFundsError(from.id); } from.withdraw(amount); to.deposit(amount); }}// Application service: orchestrates the use case — loads aggregates,// calls the domain service, manages the transaction, publishes eventsclass TransferFundsUseCase { constructor( private accounts: AccountRepository, private transferService: FundsTransferService, private uow: UnitOfWork, ) {} async execute(cmd: TransferFundsCommand): Promise<void> { await this.uow.transaction(async () => { const from = await this.accounts.findById(cmd.fromId); const to = await this.accounts.findById(cmd.toId); this.transferService.transfer(from, to, cmd.amount); await this.accounts.save(from); await this.accounts.save(to); }); }}
Context Mapping Patterns
Named relationship patterns between bounded contexts, from Evans and the DDD community — pick deliberately, don't default to shared kernel.
- Shared Kernel- two teams share a small, jointly-owned subset of the model; changes require mutual agreement
- Customer/Supplier- downstream team's needs shape upstream's roadmap via a negotiated, prioritized interface
- Conformist- downstream simply adopts the upstream model as-is, no translation layer, when negotiation power is low
- Anti-Corruption Layer- downstream translates upstream's model into its own, isolating itself from upstream changes
- Open Host Service- upstream publishes a well-defined, versioned protocol/API for many consumers instead of ad-hoc integrations
- Published Language- a shared, documented interchange format (e.g. a schema) that multiple contexts agree to speak
- Separate Ways- two contexts have no meaningful integration; duplicating a little logic is cheaper than coupling
- Partnership- two teams coordinate closely with mutual dependency and no formal customer/supplier hierarchy
Keep aggregates small — one aggregate should protect exactly one true business invariant; reaching for a giant aggregate 'to be safe' is the most common DDD mistake and it kills both performance and concurrency.