Intermediate
This intermediate-level tutorial advances Domain-Driven Design knowledge through 30 annotated code examples, covering strategic DDD patterns like Bounded Contexts and Context Mapping, along with advanced tactical patterns including Application Services, Domain Event Handlers, Factories, Specifications, and integration strategies for multi-context systems.
Bounded Contexts (Examples 31-35)
Example 31: What is a Bounded Context?
A Bounded Context is an explicit boundary within which a domain model is defined and applicable. The same concept can have different meanings in different contexts, and each context maintains its own model with its own ubiquitous language.
graph TD
A["Sales Context<br/>Customer = Buyer<br/>Product = Catalog Item"]
B["Shipping Context<br/>Customer = Delivery Address<br/>Product = Physical Package"]
C["Support Context<br/>Customer = Account<br/>Product = Service SKU"]
A -.->|Anti-Corruption Layer| B
B -.->|Anti-Corruption Layer| C
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
Key Concepts:
- Bounded Context: Explicit boundary defining model applicability
- Context-specific models: Same entity name, different meaning per context
- Ubiquitous Language per context: Terms have precise meaning within boundary
- Anti-Corruption Layer: Prevents external models from corrupting internal model
Key Takeaway: Bounded Contexts prevent model ambiguity by creating explicit boundaries where domain concepts have precise, context-specific meanings. The same term (e.g., "Customer") can mean different things in different contexts without conflict.
Why It Matters: Without Bounded Contexts, teams waste months debating "what is a Customer?" When an e-commerce platform separated their Sales, Fulfillment, and Customer Service contexts, they discovered each team needed different Customer definitions. Sales needed purchase history, Fulfillment needed shipping addresses, Support needed account status. Trying to create one unified Customer model created an oversized entity that nobody understood. Bounded Contexts let each team optimize their model for their specific needs while maintaining clean integration points through Anti-Corruption Layers.
Example 32: Bounded Context Implementation - Sales Context
Implementing a complete Bounded Context with its own model, repositories, and services isolated from other contexts.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
subgraph SalesContext[Sales Bounded Context]
A["Customer\n(Sales model)"]
B["Product\n(Sales model)"]
C["Order\n(Sales model)"]
D["SalesOrderRepository"]
E["SalesService"]
end
subgraph ShippingContext[Shipping Bounded Context]
F["Shipment\n(Shipping model)"]
G["Address\n(Shipping model)"]
H["ShipmentRepository"]
end
E -->|via ACL/event| F
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#0173B2,stroke:#000,color:#fff
style C fill:#0173B2,stroke:#000,color:#fff
style F fill:#DE8F05,stroke:#000,color:#000
style G fill:#DE8F05,stroke:#000,color:#000
// Sales Bounded Context - Customer means "Buyer with purchase history"
namespace SalesContext {
// => SalesContext: context boundary namespace
// Sales-specific Customer entity
export class Customer {
// => Sales context: Customer = buyer with payment info
private readonly customerId: string; // => Unique identifier in Sales
private readonly email: string; // => Contact for order confirmations
private readonly creditLimit: number; // => Maximum order value allowed
private orders: Order[] = []; // => Purchase history (Sales-specific)
constructor(customerId: string, email: string, creditLimit: number) {
// => Constructor: initializes new instance
this.customerId = customerId; // => Initialize customer ID
this.email = email; // => Set email for sales communications
this.creditLimit = creditLimit; // => Set credit limit for orders
}
placeOrder(order: Order): void {
// => Business rule: Sales validates against credit limit
this.ensureCreditAvailable(order.getTotalAmount());
// => Delegates to internal method
this.orders.push(order); // => Add to purchase history
// => Order placed successfully
}
private ensureCreditAvailable(amount: number): void {
// => Internal logic (not part of public API)
const totalOutstanding = this.getTotalOutstanding(); // => Calculate current debt
if (totalOutstanding + amount > this.creditLimit) {
// => Validate against credit limit
throw new Error("Credit limit exceeded");
// => Throws domain error: "Credit limit exceeded"
}
// => Credit check passed
}
private getTotalOutstanding(): number {
// => Internal logic (not part of public API)
return (
this.orders
// => Returns this.orders
.filter((o) => !o.isPaid()) // => Filter unpaid orders
.reduce((sum, o) => sum + o.getTotalAmount(), 0)
); // => Sum unpaid amounts
}
getCustomerId(): string {
// => getCustomerId(): returns string
return this.customerId; // => Expose customer ID
}
getEmail(): string {
// => getEmail(): returns string
return this.email; // => Expose email
}
}
// Sales-specific Order entity
export class Order {
private readonly orderId: string; // => Order identifier
private readonly totalAmount: number; // => Order total
private paid: boolean = false; // => Payment status
constructor(orderId: string, totalAmount: number) {
// => Constructor: initializes new instance
this.orderId = orderId; // => Initialize order ID
this.totalAmount = totalAmount; // => Set total amount
}
markAsPaid(): void {
// => markAsPaid(): returns void
this.paid = true; // => Update payment status
}
isPaid(): boolean {
// => isPaid(): returns boolean
return this.paid; // => Return payment status
}
getTotalAmount(): number {
// => getTotalAmount(): returns number
return this.totalAmount; // => Expose total amount
}
}
// Sales-specific repository
export interface CustomerRepository {
findById(customerId: string): Customer | null; // => Retrieve by ID
save(customer: Customer): void; // => Persist customer
}
}
// Usage - Sales Context operations
const salesCustomer = new SalesContext.Customer("C123", "alice@example.com", 10000);
// => salesCustomer: creditLimit=10000, orders=[]
const order = new SalesContext.Order("O456", 5000);
// => order: totalAmount=5000, paid=false
salesCustomer.placeOrder(order);
// => Order added to purchase history, credit limit checked
console.log(salesCustomer.getEmail());
// => Outputs result
// => Output: alice@example.comKey Takeaway: Each Bounded Context implements its own model with context-specific entities, value objects, and repositories. Sales Context's Customer focuses on credit limits and purchase history, completely independent of how other contexts model Customer.
Why It Matters: Bounded Context isolation enables independent evolution. When a media platform's Sales team needed to add subscription tiers, they modified their Customer model without coordinating with Streaming, Support, or Analytics teams. Each context evolved at its own pace, deployed independently, and maintained backward compatibility only at integration boundaries. This organizational independence significantly reduced feature delivery time because teams no longer waited for cross-context alignment meetings.
Example 33: Bounded Context Implementation - Shipping Context
The same domain concept (Customer) modeled differently in Shipping Context, focusing on delivery logistics rather than sales.
// Shipping Bounded Context - Customer means "Delivery recipient"
namespace ShippingContext {
// => ShippingContext: context boundary namespace
// Shipping-specific Customer entity
export class Customer {
// => Shipping context: Customer = delivery address holder
private readonly customerId: string; // => Unique identifier in Shipping
private readonly name: string; // => Recipient name for delivery
private readonly addresses: DeliveryAddress[] = []; // => Delivery locations
constructor(customerId: string, name: string) {
// => Constructor: initializes new instance
this.customerId = customerId; // => Initialize customer ID
this.name = name; // => Set recipient name
}
addDeliveryAddress(address: DeliveryAddress): void {
// => addDeliveryAddress(): returns void
this.addresses.push(address); // => Add delivery location
// => Address added to customer's delivery options
}
getDefaultAddress(): DeliveryAddress | null {
// => getDefaultAddress(): returns DeliveryAddress | null
const defaultAddr = this.addresses.find((a) => a.isDefault());
// => Find default address
return defaultAddr || null; // => Return default or null
}
getCustomerId(): string {
// => getCustomerId(): returns string
return this.customerId; // => Expose customer ID
}
}
// Shipping-specific value object
export class DeliveryAddress {
private readonly street: string; // => Street address
private readonly city: string; // => City name
private readonly zipCode: string; // => Postal code
private readonly country: string; // => Country
private readonly isDefaultAddress: boolean; // => Default flag
constructor(street: string, city: string, zipCode: string, country: string, isDefaultAddress: boolean = false) {
// => Constructor: initializes new instance
this.street = street; // => Initialize street
this.city = city; // => Initialize city
this.zipCode = zipCode; // => Initialize zip code
this.country = country; // => Initialize country
this.isDefaultAddress = isDefaultAddress; // => Set default flag
}
isDefault(): boolean {
// => isDefault(): returns boolean
return this.isDefaultAddress; // => Return default status
}
getFullAddress(): string {
// => getFullAddress(): returns string
return `${this.street}, ${this.city}, ${this.zipCode}, ${this.country}`;
// => Format complete address string
}
}
// Shipment entity - Shipping context specific
export class Shipment {
private readonly shipmentId: string; // => Shipment identifier
private readonly customerId: string; // => Reference to customer
private readonly address: DeliveryAddress; // => Delivery destination
private status: ShipmentStatus = "PENDING"; // => Current status
constructor(shipmentId: string, customerId: string, address: DeliveryAddress) {
// => Constructor: initializes new instance
this.shipmentId = shipmentId; // => Initialize shipment ID
this.customerId = customerId; // => Link to customer
this.address = address; // => Set delivery address
}
ship(): void {
// => ship(): returns void
if (this.status !== "PENDING") {
// => Validate current status
throw new Error("Shipment already processed");
// => Throws domain error: "Shipment already processed"
}
this.status = "SHIPPED"; // => Update to shipped
// => Shipment marked as shipped
}
getStatus(): ShipmentStatus {
// => getStatus(): returns ShipmentStatus
return this.status; // => Return current status
}
}
type ShipmentStatus = "PENDING" | "SHIPPED" | "DELIVERED";
// => Type definition for shipment states
}
// Usage - Shipping Context operations
const shippingCustomer = new ShippingContext.Customer("C123", "Alice Smith");
// => shippingCustomer: name="Alice Smith", addresses=[]
const address = new ShippingContext.DeliveryAddress("123 Main St", "Seattle", "98101", "USA", true);
// => address: street="123 Main St", city="Seattle", isDefault=true
shippingCustomer.addDeliveryAddress(address);
// => Address added to customer's delivery options
const shipment = new ShippingContext.Shipment("S789", "C123", address);
// => shipment: status="PENDING", address=address
shipment.ship();
// => shipment.status becomes "SHIPPED"
console.log(shipment.getStatus());
// => Outputs result
// => Output: SHIPPEDKey Takeaway: Shipping Context models Customer completely differently than Sales Context. Same customerId links the concepts, but Shipping focuses on delivery addresses and logistics, not credit limits or purchase history. Each context optimizes its model for its specific responsibilities.
Why It Matters: Context-specific models prevent feature bloat. Shipping systems integrated with e-commerce platforms need shipping addresses and package dimensions, not customer credit scores or purchase preferences. By maintaining separate Shipping and Sales contexts, systems exchange only necessary data through well-defined interfaces, reducing coupling and API payload sizes. This separation enables Shipping systems to serve multiple Sales systems without modification.
Example 34: Context Mapping - Shared Kernel Pattern
Two Bounded Contexts sharing a common subset of the domain model where tight coordination is acceptable.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Shared Kernel\nCustomer ID\nCurrency\n(shared model)"]
B["Sales Context\nuses shared kernel"]
C["Billing Context\nuses shared kernel"]
A -->|shared by| B
A -->|shared by| C
B <-->|coordinate changes| C
style A fill:#029E73,stroke:#000,color:#fff
style B fill:#0173B2,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#000
// Shared Kernel - Common model shared between contexts
namespace SharedKernel {
// => SharedKernel: context boundary namespace
// Shared value object - used by multiple contexts
export class Money {
private readonly amount: number; // => Monetary amount
private readonly currency: string; // => Currency code (USD, EUR, etc.)
constructor(amount: number, currency: string) {
// => Initialize object with parameters
if (amount < 0) {
// => Validate non-negative amount
throw new Error("Amount cannot be negative");
// => Raise domain exception
}
this.amount = amount; // => Initialize amount
this.currency = currency; // => Initialize currency
}
add(other: Money): Money {
// => add(): returns Money
this.ensureSameCurrency(other); // => Validate currency match
return new Money(this.amount + other.amount, this.currency);
// => Return new Money with combined amount
}
private ensureSameCurrency(other: Money): void {
// => Internal logic (not part of public API)
if (this.currency !== other.currency) {
// => Validate currencies match
throw new Error("Cannot operate on different currencies");
// => Raise domain exception
}
// => Currency validation passed
}
getAmount(): number {
// => getAmount(): returns number
return this.amount; // => Expose amount
}
getCurrency(): string {
// => getCurrency(): returns string
return this.currency; // => Expose currency
}
}
// Shared enum - product category taxonomy
export enum ProductCategory {
ELECTRONICS = "ELECTRONICS", // => Electronic goods
CLOTHING = "CLOTHING", // => Apparel items
BOOKS = "BOOKS", // => Published works
FOOD = "FOOD", // => Consumable products
}
}
// Billing Context - uses shared kernel
namespace BillingContext {
// => BillingContext: context boundary namespace
import Money = SharedKernel.Money; // => Import shared Money type
export class Invoice {
private readonly invoiceId: string; // => Invoice identifier
private readonly items: InvoiceItem[] = []; // => Line items
constructor(invoiceId: string) {
// => Constructor: initializes new instance
this.invoiceId = invoiceId; // => Initialize invoice ID
}
addItem(item: InvoiceItem): void {
// => addItem(): returns void
this.items.push(item); // => Add line item
// => Item added to invoice
}
getTotal(): Money {
// => getTotal(): returns Money
if (this.items.length === 0) {
// => Check for empty invoice
return new Money(0, "USD"); // => Return zero amount
}
return (
// => Return result to caller
this.items
.map((item) => item.getPrice()) // => Extract prices
.reduce((sum, price) => sum.add(price))
); // => Sum all prices
}
}
export class InvoiceItem {
private readonly description: string; // => Item description
private readonly price: Money; // => Item price (shared type)
constructor(description: string, price: Money) {
// => Constructor: initializes new instance
this.description = description; // => Initialize description
this.price = price; // => Initialize price
}
getPrice(): Money {
// => getPrice(): returns Money
return this.price; // => Expose price
}
}
}
// Accounting Context - also uses shared kernel
namespace AccountingContext {
// => AccountingContext: context boundary namespace
import Money = SharedKernel.Money; // => Import shared Money type
export class Transaction {
private readonly transactionId: string; // => Transaction identifier
private readonly amount: Money; // => Transaction amount (shared type)
private readonly type: "DEBIT" | "CREDIT"; // => Transaction type
constructor(transactionId: string, amount: Money, type: "DEBIT" | "CREDIT") {
// => Constructor: initializes new instance
this.transactionId = transactionId; // => Initialize transaction ID
this.amount = amount; // => Initialize amount
this.type = type; // => Set transaction type
}
getAmount(): Money {
// => getAmount(): returns Money
return this.amount; // => Expose amount
}
getType(): string {
// => getType(): returns string
return this.type; // => Expose transaction type
}
}
}
// Usage - Both contexts use shared Money type
const invoiceItem = new BillingContext.InvoiceItem("Laptop", new SharedKernel.Money(1200, "USD"));
// => invoiceItem: description="Laptop", price=Money{1200, USD}
const invoice = new BillingContext.Invoice("INV-001");
// => invoice: invoiceId="INV-001", items=[]
invoice.addItem(invoiceItem);
// => Item added to invoice
const total = invoice.getTotal();
// => total: Money{1200, USD}
console.log(`Total: ${total.getAmount()} ${total.getCurrency()}`);
// => Outputs result
// => Output: Total: 1200 USD
const transaction = new AccountingContext.Transaction("TXN-001", new SharedKernel.Money(1200, "USD"), "DEBIT");
// => transaction: amount=Money{1200, USD}, type="DEBIT"
console.log(`Transaction: ${transaction.getAmount().getAmount()}`);
// => Outputs result
// => Output: Transaction: 1200Key Takeaway: Shared Kernel reduces duplication for commonly used types (Money, Address, etc.) that have identical semantics across contexts. Both teams must coordinate changes to shared code, making this pattern suitable only when tight collaboration is acceptable.
Why It Matters: Shared Kernels prevent value object sprawl. A payment platform's Billing and Accounting contexts share Money, Currency, and Account value objects because these have identical semantics in both contexts. This eliminated multiple Money implementations with subtle differences (rounding rules, currency conversion) that caused financial reconciliation errors. However, Shared Kernel requires coordination—both teams must approve changes, making it unsuitable for loosely coupled teams. Use sparingly for truly universal concepts.
Example 35: Context Mapping - Customer-Supplier Pattern
One context (Supplier) provides services to another context (Customer), with the Customer depending on the Supplier's API.
// Supplier Context - Provides product catalog service
namespace ProductCatalogContext {
// Supplier's public API
export interface ProductCatalogService {
getProduct(productId: string): ProductDTO | null; // => Public interface
searchProducts(query: string): ProductDTO[]; // => Search functionality
}
// Data Transfer Object - Supplier's contract
export interface ProductDTO {
// => Communicates domain intent
productId: string; // => Product identifier
name: string; // => Product name
description: string; // => Product description
// => Validates business rule
price: number; // => Price in cents
// => Enforces invariant
currency: string; // => Currency code
}
// => Validates business rule
// Internal implementation (private to Supplier)
class Product {
constructor(
// => Initialize object with parameters
private readonly productId: string,
// => Encapsulated state, not directly accessible
private readonly name: string,
// => Encapsulated state, not directly accessible
private readonly description: string,
// => Encapsulated state, not directly accessible
private readonly price: number,
// => Encapsulated state, not directly accessible
private readonly currency: string,
// => Encapsulated state, not directly accessible
) {}
// => Enforces invariant
toDTO(): ProductDTO {
// => Convert internal model to public DTO
return {
productId: this.productId,
// => Business rule enforced here
name: this.name,
// => Execution delegated to domain service
description: this.description,
// => Aggregate boundary enforced here
price: this.price,
// => Domain event triggered or handled
currency: this.currency,
// => Cross-context interaction point
};
}
}
// Supplier's service implementation
export class ProductCatalogServiceImpl implements ProductCatalogService {
private products: Map<string, Product> = new Map();
// => Encapsulated field (not publicly accessible)
// => Internal product storage
constructor() {
// => Initialize object with parameters
// Seed with sample data
const laptop = new Product("P1", "Laptop", "15-inch laptop", 120000, "USD");
// => laptop: price=120000 cents = $1200
this.products.set("P1", laptop);
// => Delegates to internal method
// => Product stored in catalog
}
getProduct(productId: string): ProductDTO | null {
const product = this.products.get(productId);
// => Retrieve product by ID
return product ? product.toDTO() : null;
// => Return DTO or null if not found
}
searchProducts(query: string): ProductDTO[] {
const results: ProductDTO[] = [];
// => Create data structure
this.products.forEach((product) => {
// => Iterate all products
if (product["name"].toLowerCase().includes(query.toLowerCase())) {
// => Check if name matches query
results.push(product.toDTO());
// => Add matching product to results
}
// => Communicates domain intent
});
return results; // => Return matching products
}
}
}
// => Validates business rule
// Customer Context - Depends on Supplier's service
namespace OrderManagementContext {
// => Enforces invariant
import ProductDTO = ProductCatalogContext.ProductDTO;
// => Business rule enforced here
import ProductCatalogService = ProductCatalogContext.ProductCatalogService;
// => Import Supplier's public contracts
// Customer's domain model
export class OrderItem {
private readonly productId: string; // => Reference to catalog product
private readonly productName: string; // => Cached name
private readonly price: number; // => Price at order time
private readonly quantity: number; // => Quantity ordered
constructor(productId: string, productName: string, price: number, quantity: number) {
// => Method body begins here
this.productId = productId; // => Initialize product ID
this.productName = productName; // => Initialize cached name
this.price = price; // => Initialize price snapshot
this.quantity = quantity; // => Initialize quantity
}
getTotalPrice(): number {
return this.price * this.quantity; // => Calculate line total
}
getProductName(): string {
return this.productName; // => Expose product name
}
}
// Customer's service using Supplier
export class OrderService {
constructor(private catalogService: ProductCatalogService) {}
// => Inject Supplier's service
createOrderItem(productId: string, quantity: number): OrderItem {
const productDTO = this.catalogService.getProduct(productId);
// => Call Supplier's API to get product
if (!productDTO) {
// => Validate product exists
throw new Error("Product not found");
// => Raise domain exception
}
return new OrderItem(productDTO.productId, productDTO.name, productDTO.price, quantity);
// => Create order item with product data from Supplier
}
}
}
// Usage - Customer depends on Supplier
const catalogService = new ProductCatalogContext.ProductCatalogServiceImpl();
// => Supplier service instantiated
const orderService = new OrderManagementContext.OrderService(catalogService);
// => Customer service depends on Supplier
const orderItem = orderService.createOrderItem("P1", 2);
// => Calls Supplier API: getProduct("P1")
// => Creates OrderItem with product data
console.log(`Ordered: ${orderItem.getProductName()}, Total: ${orderItem.getTotalPrice()}`);
// => Outputs result
// => Output: Ordered: Laptop, Total: 240000Key Takeaway: Customer-Supplier pattern establishes clear dependency direction. Supplier context defines the contract (DTOs, interfaces), Customer context depends on it. Supplier evolves independently but must maintain backward compatibility for Customer.
Why It Matters: Customer-Supplier clarifies API ownership and evolution responsibility. When an e-commerce platform's Inventory context (Supplier) serves Order Management (Customer), Inventory team owns the API contract and ensures backward compatibility. Customer teams can't demand breaking changes without negotiation, preventing the chaos of bidirectional dependencies. This pattern significantly reduced integration failures because API contracts became explicit, versioned, and ownership was clear—Supplier must maintain stability, Customer must adapt to contract.
Context Mapping Patterns (Examples 36-42)
Example 36: Anti-Corruption Layer (ACL)
Protecting your context's domain model from external systems by translating external concepts into your ubiquitous language.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph LR
A["External System\n(legacy/third-party)\nuses foreign concepts"]
B["Anti-Corruption Layer\ntranslates concepts\nadapter + translator"]
C["Your Domain\nClean ubiquitous\nlanguage model"]
A -->|foreign model| B
B -->|clean domain model| C
C -.->|never sees| A
style A fill:#CA9161,stroke:#000,color:#fff
style B fill:#CC78BC,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
// External Payment Gateway - Third-party system with its own model
namespace ExternalPaymentGateway {
// => ExternalPaymentGateway: context boundary namespace
// External system's data structure (we don't control this)
export interface PaymentResponse {
transaction_id: string; // => Snake case naming
status_code: number; // => Numeric status codes
amount_cents: number; // => Amount in cents
currency_iso: string; // => ISO currency code
timestamp_ms: number; // => Unix timestamp
customer_ref: string; // => External customer reference
}
// Simulated external API
export class PaymentGatewayAPI {
processPayment(amount: number, currency: string, customerRef: string): PaymentResponse {
// => External system processes payment
return {
// => Returns {
transaction_id: `TXN-${Date.now()}`, // => Generate transaction ID
status_code: 200, // => 200 = success in their system
amount_cents: amount,
currency_iso: currency,
timestamp_ms: Date.now(),
// => Execute method
customer_ref: customerRef,
};
}
}
}
// Our Bounded Context - with its own domain model
namespace PaymentContext {
// => PaymentContext: context boundary namespace
// Our domain model - uses our ubiquitous language
export class Payment {
private readonly paymentId: string; // => Our naming: paymentId
private readonly amount: Money; // => Our Money value object
private readonly status: PaymentStatus; // => Our status enum
private readonly processedAt: Date; // => Our Date type
constructor(paymentId: string, amount: Money, status: PaymentStatus, processedAt: Date) {
// => Constructor: initializes new instance
this.paymentId = paymentId; // => Initialize payment ID
this.amount = amount; // => Initialize amount
this.status = status; // => Initialize status
this.processedAt = processedAt; // => Initialize timestamp
}
isSuccessful(): boolean {
// => isSuccessful(): returns boolean
return this.status === PaymentStatus.COMPLETED;
// => Check if payment succeeded
}
getPaymentId(): string {
// => getPaymentId(): returns string
return this.paymentId; // => Expose payment ID
}
getAmount(): Money {
// => getAmount(): returns Money
return this.amount; // => Expose amount
}
}
export class Money {
constructor(
// => Initialize object with parameters
private readonly amount: number,
// => Encapsulated state, not directly accessible
private readonly currency: string,
// => Encapsulated state, not directly accessible
) {}
// => Constructor body empty: no additional initialization needed
getAmount(): number {
// => getAmount(): returns number
return this.amount; // => Expose amount
}
getCurrency(): string {
// => getCurrency(): returns string
return this.currency; // => Expose currency
}
}
export enum PaymentStatus {
PENDING = "PENDING", // => Payment initiated
COMPLETED = "COMPLETED", // => Payment succeeded
FAILED = "FAILED", // => Payment failed
}
// Anti-Corruption Layer - Translates external model to our model
export class PaymentGatewayAdapter {
constructor(private readonly gateway: ExternalPaymentGateway.PaymentGatewayAPI) {}
// => Adapter wraps external system
processPayment(amount: Money, customerRef: string): Payment {
// => Public method uses our domain model
const response = this.gateway.processPayment(amount.getAmount(), amount.getCurrency(), customerRef);
// => Call external API (uses their model)
return this.translateToPayment(response);
// => Translate external response to our domain model
}
private translateToPayment(response: ExternalPaymentGateway.PaymentResponse): Payment {
// => Internal logic (not part of public API)
// => ACL translation logic
const money = new Money(response.amount_cents, response.currency_iso);
// => Convert external amount to our Money type
const status = this.translateStatus(response.status_code);
// => Convert external status code to our enum
const processedAt = new Date(response.timestamp_ms);
// => Convert Unix timestamp to Date
return new Payment(response.transaction_id, money, status, processedAt);
// => Create our domain model from external data
}
private translateStatus(statusCode: number): PaymentStatus {
// => Internal logic (not part of public API)
// => Map external status codes to our enum
switch (statusCode) {
case 200:
return PaymentStatus.COMPLETED; // => 200 → COMPLETED
case 400:
case 500:
return PaymentStatus.FAILED; // => Error codes → FAILED
default:
return PaymentStatus.PENDING; // => Unknown → PENDING
}
}
}
}
// Usage - ACL protects our domain from external model
const externalGateway = new ExternalPaymentGateway.PaymentGatewayAPI();
// => External system instantiated
const adapter = new PaymentContext.PaymentGatewayAdapter(externalGateway);
// => ACL adapter wraps external system
const money = new PaymentContext.Money(5000, "USD");
// => Our Money value object
const payment = adapter.processPayment(money, "CUST-123");
// => Process payment through ACL
// => ACL calls external API, translates response to our model
console.log(`Payment ${payment.getPaymentId()} successful: ${payment.isSuccessful()}`);
// => Outputs result
// => Output: Payment TXN-[timestamp] successful: trueKey Takeaway: Anti-Corruption Layer (ACL) shields your domain model from external systems by translating between your ubiquitous language and external contracts. This prevents external models from corrupting your carefully crafted domain model with their naming conventions, data structures, and business rules.
Why It Matters: ACLs prevent technical debt from external integrations. When a ride-sharing platform integrated with a mapping service API, they built an ACL that translated the external "lat_lng" objects to their own "GeoLocation" domain model. When the external API changed, only the ACL needed updates—none of the platform's domain services changed. Without ACL, the API change would have required updating numerous files across multiple microservices. ACLs isolate integration complexity to a single boundary, protecting domain purity.
Example 37: Published Language Pattern
Creating a well-documented, stable exchange format (like JSON Schema or Protocol Buffers) that multiple contexts can use for integration.
// Published Language - Shared contract for Order events
namespace OrderEventPublishedLanguage {
// Version 1.0 - Stable, documented contract
export interface OrderCreatedEvent {
eventType: "OrderCreated"; // => Event discriminator
version: "1.0"; // => Schema version for compatibility
timestamp: string; // => ISO 8601 timestamp
payload: {
orderId: string; // => Order identifier
customerId: string; // => Customer reference
// => Communicates domain intent
items: Array<{
// => Order line items
productId: string; // => Product reference
quantity: number; // => Quantity ordered
priceAtOrder: number; // => Price snapshot in cents
// => Validates business rule
}>;
// => Enforces invariant
totalAmount: number; // => Total in cents
currency: string; // => Currency code (ISO 4217)
};
}
// => Validates business rule
// Validator for Published Language contract
export class OrderCreatedEventValidator {
static validate(event: any): event is OrderCreatedEvent {
// => Type guard for validation
return (
event.eventType === "OrderCreated" && // => Check event type
event.version === "1.0" && // => Check schema version
typeof event.timestamp === "string" && // => Validate timestamp
typeof event.payload.orderId === "string" && // => Validate orderId
Array.isArray(event.payload.items) && // => Validate items array
typeof event.payload.totalAmount === "number" // => Validate total
);
// => Enforces invariant
}
}
}
// Context 1 (Publisher) - Order Management publishes events
namespace OrderManagementContext {
import OrderCreatedEvent = OrderEventPublishedLanguage.OrderCreatedEvent;
// => Import Published Language contract
export class Order {
constructor(
// => Initialize object with parameters
private readonly orderId: string,
// => Encapsulated state, not directly accessible
private readonly customerId: string,
// => Encapsulated state, not directly accessible
private readonly items: OrderItem[],
// => Encapsulated state, not directly accessible
private readonly totalAmount: number,
// => Encapsulated state, not directly accessible
) {}
// Translate internal model to Published Language
toOrderCreatedEvent(): OrderCreatedEvent {
// => Convert domain model to Published Language
return {
eventType: "OrderCreated",
// => DDD tactical pattern applied
version: "1.0",
// => Entity state transition managed
timestamp: new Date().toISOString(), // => ISO 8601 format
payload: {
orderId: this.orderId,
// => Transaction boundary maintained
customerId: this.customerId,
// => Entity state transition managed
items: this.items.map((item) => ({
// => Map domain items to Published Language
productId: item.productId,
// => Domain model consistency maintained
quantity: item.quantity,
// => Communicates domain intent
priceAtOrder: item.price,
// => Domain operation executes here
})),
totalAmount: this.totalAmount,
// => Validates business rule
currency: "USD",
// => Enforces invariant
},
};
}
}
export class OrderItem {
constructor(
// => Initialize object with parameters
public readonly productId: string,
public readonly quantity: number,
public readonly price: number,
) {}
}
}
// Context 2 (Subscriber) - Billing consumes events
namespace BillingContext {
import OrderCreatedEvent = OrderEventPublishedLanguage.OrderCreatedEvent;
// => Entity state transition managed
import Validator = OrderEventPublishedLanguage.OrderCreatedEventValidator;
// => Import Published Language contract and validator
export class BillingService {
handleOrderCreated(event: OrderCreatedEvent): void {
// => Receive event in Published Language format
if (!Validator.validate(event)) {
// => Validate against Published Language schema
throw new Error("Invalid OrderCreatedEvent");
// => Raise domain exception
}
const invoice = this.createInvoice(event);
// => Translate Published Language to our domain model
console.log(`Invoice created: ${invoice.invoiceId}`);
// => Outputs result
// => Process in our context's ubiquitous language
}
// => Communicates domain intent
private createInvoice(event: OrderCreatedEvent): Invoice {
// => Internal logic (not part of public API)
// => Convert Published Language to our domain model
return new Invoice(
// => Communicates domain intent
`INV-${event.payload.orderId}`, // => Generate invoice ID
event.payload.customerId,
// => Domain operation executes here
event.payload.totalAmount,
// => Modifies aggregate internal state
);
// => Validates business rule
}
// => Enforces invariant
}
class Invoice {
constructor(
// => Initialize object with parameters
public readonly invoiceId: string,
public readonly customerId: string,
public readonly amount: number,
) {}
}
}
// Usage - Published Language enables clean integration
const orderItem = new OrderManagementContext.OrderItem("P123", 2, 5000);
// => orderItem: productId="P123", quantity=2, price=5000
const order = new OrderManagementContext.Order("O456", "C789", [orderItem], 10000);
// => order: orderId="O456", totalAmount=10000
const event = order.toOrderCreatedEvent();
// => Convert to Published Language format
console.log(`Event version: ${event.version}, Type: ${event.eventType}`);
// => Outputs result
// => Output: Event version: 1.0, Type: OrderCreated
const billingService = new BillingContext.BillingService();
// => Billing context service
billingService.handleOrderCreated(event);
// => Consume Published Language event
// => Output: Invoice created: INV-O456Key Takeaway: Published Language establishes a documented, versioned contract for inter-context communication. Both publisher and subscriber translate between their internal models and the Published Language, enabling independent evolution as long as the contract is maintained.
Why It Matters: Published Language prevents integration brittleness. A payment platform's webhook events use Published Language (JSON schemas with semantic versioning). When their internal Order model added new fields, their webhook schema remained unchanged, preventing breaking changes for API consumers. Publishers evolve internally, subscribers evolve internally, and only the stable Published Language contract binds them—significantly reducing cross-team coordination needs while maintaining integration stability.
Example 38: Conformist Pattern
When your context must conform to an external system's model because you have no negotiating power to change it.
// External System - Legacy ERP we must integrate with
namespace LegacyERPSystem {
// => LegacyERPSystem: context boundary namespace
// ERP's model (we don't control this)
export class ERPCustomer {
cust_id: string; // => Legacy naming convention
full_name_text: string; // => Verbose field names
email_addr: string; // => Abbreviated naming
credit_limit_cents: number; // => Amount in cents
status_flag: number; // => 1=active, 0=inactive
constructor(
// => Initialize object with parameters
cust_id: string,
full_name_text: string,
email_addr: string,
credit_limit_cents: number,
status_flag: number,
) {
this.cust_id = cust_id; // => Initialize customer ID
this.full_name_text = full_name_text; // => Initialize name
this.email_addr = email_addr; // => Initialize email
this.credit_limit_cents = credit_limit_cents; // => Initialize credit limit
this.status_flag = status_flag; // => Initialize status
}
}
export class ERPService {
private customers: Map<string, ERPCustomer> = new Map();
// => Encapsulated field (not publicly accessible)
// => ERP customer storage
getCustomer(cust_id: string): ERPCustomer | null {
// => getCustomer(): returns ERPCustomer | null
return this.customers.get(cust_id) || null;
// => Retrieve customer by legacy ID format
}
saveCustomer(customer: ERPCustomer): void {
// => saveCustomer(): returns void
this.customers.set(customer.cust_id, customer);
// => Delegates to internal method
// => Store customer in ERP
}
}
}
// Our Context - Conformist approach (no translation layer)
namespace SalesContext {
// => SalesContext: context boundary namespace
import ERPCustomer = LegacyERPSystem.ERPCustomer;
import ERPService = LegacyERPSystem.ERPService;
// => Direct import of ERP types (conformist)
// We conform to ERP's model instead of maintaining our own
export class SalesService {
constructor(private erpService: ERPService) {}
// => Inject ERP service
createCustomer(name: string, email: string, creditLimit: number): ERPCustomer {
// => Use ERP model directly in our domain
const customer = new ERPCustomer(
// => customer = new ERPCustomer(
`CUST-${Date.now()}`, // => Generate ERP-format ID
name,
email,
creditLimit * 100, // => Convert to cents for ERP
1, // => 1 = active in ERP's convention
);
// => Create ERP customer directly
this.erpService.saveCustomer(customer);
// => Delegates to internal method
// => Save using ERP service
return customer; // => Return ERP model
}
isCustomerActive(customer: ERPCustomer): boolean {
// => Our business logic uses ERP conventions
return customer.status_flag === 1;
// => Check status using ERP's numeric flag
}
getCreditLimitDollars(customer: ERPCustomer): number {
// => Our helper methods work with ERP model
return customer.credit_limit_cents / 100;
// => Convert cents to dollars
}
}
}
// Usage - Conformist pattern (accept external model)
const erpService = new LegacyERPSystem.ERPService();
// => ERP service instantiated
const salesService = new SalesContext.SalesService(erpService);
// => Our service conforms to ERP
const customer = salesService.createCustomer("Alice", "alice@example.com", 5000);
// => Creates ERPCustomer directly
console.log(`Customer: ${customer.full_name_text}, Active: ${salesService.isCustomerActive(customer)}`);
// => Outputs result
// => Output: Customer: Alice, Active: true
console.log(`Credit limit: $${salesService.getCreditLimitDollars(customer)}`);
// => Outputs result
// => Output: Credit limit: $5000Key Takeaway: Conformist pattern accepts the external system's model without translation. Use when the external system is non-negotiable (legacy ERP, government API) and the cost of maintaining an Anti-Corruption Layer exceeds the benefit. Your domain adopts their naming, data structures, and conventions.
Why It Matters: Conformist reduces integration overhead when you lack leverage. Small startups integrating with Salesforce or SAP often use Conformist because building ACLs for massive external systems is prohibitively expensive. The trade-off: your domain model becomes coupled to external conventions, but you avoid maintaining translation layers. Use Conformist for stable, dominant external systems where you're a small player—save ACL investment for systems you can influence or that change frequently.
Example 39: Open Host Service Pattern
Defining a clear protocol for accessing your context's services, making it easy for multiple consumers to integrate.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Open Host Service\nPublished REST API\nstable protocol"]
B["Consumer A\nMobile App"]
C["Consumer B\nPartner System"]
D["Consumer C\nInternal Service"]
E["Your Domain\nBounded Context"]
A -->|serves| B
A -->|serves| C
A -->|serves| D
E -->|exposes via| A
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#000
style C fill:#DE8F05,stroke:#000,color:#000
style D fill:#DE8F05,stroke:#000,color:#000
style E fill:#029E73,stroke:#000,color:#fff
// Our Bounded Context - Inventory Management (Host)
namespace InventoryContext {
// Internal domain model (private)
class Product {
constructor(
// => Initialize object with parameters
private readonly productId: string,
// => Encapsulated state, not directly accessible
private readonly name: string,
// => Encapsulated state, not directly accessible
private stockLevel: number,
// => Encapsulated field (not publicly accessible)
) {}
reserveStock(quantity: number): void {
if (this.stockLevel < quantity) {
// => Validate sufficient stock
throw new Error("Insufficient stock");
// => Raise domain exception
}
this.stockLevel -= quantity; // => Reduce available stock
// => Modifies stockLevel
// => State change operation
// => Stock reserved successfully
}
// => Validates business rule
getStockLevel(): number {
return this.stockLevel; // => Expose current stock
}
// => Enforces invariant
getProductId(): string {
return this.productId; // => Expose product ID
// => Communicates domain intent
}
getName(): string {
return this.name; // => Expose product name
}
}
// Public API - Open Host Service
export interface InventoryService {
// => Public interface for external contexts
checkAvailability(productId: string): StockAvailabilityDTO;
// => Check stock availability
reserveStock(request: StockReservationRequest): StockReservationResult;
// => Reserve stock for order
}
// Public DTOs - Well-documented contracts
export interface StockAvailabilityDTO {
productId: string; // => Product identifier
// => Validates business rule
productName: string; // => Product name
// => Enforces invariant
availableQuantity: number; // => Current stock level
isAvailable: boolean; // => Availability flag
}
export interface StockReservationRequest {
productId: string; // => Product to reserve
quantity: number; // => Quantity to reserve
reservationId: string; // => Idempotency key
}
export interface StockReservationResult {
success: boolean; // => Reservation outcome
reservationId: string; // => Idempotency key
remainingStock: number; // => Stock after reservation
}
// Open Host Service implementation
export class InventoryServiceImpl implements InventoryService {
private products: Map<string, Product> = new Map();
// => Encapsulated field (not publicly accessible)
// => Internal product storage
constructor() {
// => Initialize object with parameters
// Seed initial inventory
this.products.set("P1", new Product("P1", "Laptop", 50));
// => Delegates to internal method
// => Product P1: stockLevel=50
this.products.set("P2", new Product("P2", "Mouse", 200));
// => Delegates to internal method
// => Product P2: stockLevel=200
}
checkAvailability(productId: string): StockAvailabilityDTO {
// => Public method: check stock
const product = this.products.get(productId);
// => Retrieve product from internal model
if (!product) {
// => Product not found
return {
productId,
// => Entity state transition managed
productName: "Unknown",
// => Domain model consistency maintained
availableQuantity: 0,
// => Communicates domain intent
isAvailable: false,
// => Domain operation executes here
};
// => Return unavailable DTO
}
return {
// => Convert internal model to public DTO
productId: product.getProductId(),
// => Execute method
productName: product.getName(),
// => Execute method
availableQuantity: product.getStockLevel(),
// => Execute method
isAvailable: product.getStockLevel() > 0,
// => Execute method
};
// => Return availability DTO
}
// => Validates business rule
reserveStock(request: StockReservationRequest): StockReservationResult {
// => Public method: reserve stock
const product = this.products.get(request.productId);
// => Retrieve product
if (!product) {
// => Product not found
return {
success: false,
// => Enforces invariant
reservationId: request.reservationId,
// => Business rule enforced here
remainingStock: 0,
// => Execution delegated to domain service
};
}
try {
product.reserveStock(request.quantity);
// => Attempt reservation on domain model
return {
success: true,
// => DDD tactical pattern applied
reservationId: request.reservationId,
// => Invariant validation executed
remainingStock: product.getStockLevel(),
// => Execute method
};
// => Return success result
} catch (error) {
// => Reservation failed (insufficient stock)
return {
success: false,
// => Transaction boundary maintained
reservationId: request.reservationId,
// => Entity state transition managed
remainingStock: product.getStockLevel(),
// => Execute method
};
// => Return failure result
}
}
// => Communicates domain intent
}
}
// Consumer Context - Order Management uses Open Host Service
namespace OrderContext {
// => Validates business rule
import InventoryService = InventoryContext.InventoryService;
// => Enforces invariant
import StockReservationRequest = InventoryContext.StockReservationRequest;
// => Import public contracts from Open Host
export class OrderService {
constructor(private inventoryService: InventoryService) {}
// => Depend on Open Host Service interface
placeOrder(productId: string, quantity: number): void {
// => Place order using inventory service
const availability = this.inventoryService.checkAvailability(productId);
// => Check availability via Open Host Service
if (!availability.isAvailable || availability.availableQuantity < quantity) {
// => Validate stock sufficient
throw new Error("Product not available");
// => Raise domain exception
}
const result = this.inventoryService.reserveStock({
// => Reserve stock via Open Host Service
productId,
// => Execution delegated to domain service
quantity,
// => Aggregate boundary enforced here
reservationId: `RES-${Date.now()}`,
// => Execute method
});
if (!result.success) {
// => Validate reservation succeeded
throw new Error("Reservation failed");
// => Raise domain exception
}
console.log(`Order placed. Remaining stock: ${result.remainingStock}`);
// => Outputs result
// => Output success message
}
}
}
// Usage - Multiple consumers can easily integrate
const inventoryService = new InventoryContext.InventoryServiceImpl();
// => Open Host Service instantiated
const availability = inventoryService.checkAvailability("P1");
// => Check stock via public API
console.log(`${availability.productName}: ${availability.availableQuantity} available`);
// => Outputs result
// => Output: Laptop: 50 available
const orderService = new OrderContext.OrderService(inventoryService);
// => Consumer uses Open Host Service
orderService.placeOrder("P1", 5);
// => Order placed. Remaining stock: 45Key Takeaway: Open Host Service provides a well-documented, stable public API that makes integration easy for multiple consumers. Internal domain model remains private; only DTOs and service interfaces are exposed. This pattern standardizes access and reduces integration complexity.
Why It Matters: Open Host Service reduces integration fragmentation. Major cloud storage providers use Open Host Services (RESTful APIs with multi-language SDKs), enabling widespread integration with stable contracts. Before standardizing on Open Host pattern, many early cloud platforms had numerous different integration patterns, requiring custom code per service. Open Host Service with stable contracts significantly reduces integration time and enables self-service integration without direct platform team involvement.
Example 40: Separate Ways Pattern
Acknowledging that two contexts have no integration needs and can evolve independently without communication.
// Context 1 - Employee HR Management
namespace HRContext {
// => HRContext: context boundary namespace
export class Employee {
// => HR's Employee model
private readonly employeeId: string; // => HR identifier
private readonly fullName: string; // => Legal name
private readonly department: string; // => Org structure
private readonly salary: number; // => Compensation info
private readonly hireDate: Date; // => Employment start date
constructor(employeeId: string, fullName: string, department: string, salary: number, hireDate: Date) {
// => Constructor: initializes new instance
this.employeeId = employeeId; // => Initialize employee ID
this.fullName = fullName; // => Initialize name
this.department = department; // => Initialize department
this.salary = salary; // => Initialize salary
this.hireDate = hireDate; // => Initialize hire date
}
promoteEmployee(newDepartment: string, newSalary: number): void {
// => HR operation: promotion
// Note: No integration with CustomerSupport context
console.log(`Promoted ${this.fullName} to ${newDepartment}`);
// => Delegates to internal method
// => Outputs result
// => HR-specific business logic
}
getEmployeeId(): string {
// => getEmployeeId(): returns string
return this.employeeId; // => Expose employee ID
}
}
export class HRService {
private employees: Map<string, Employee> = new Map();
// => Encapsulated field (not publicly accessible)
// => HR employee records
hireEmployee(employee: Employee): void {
// => hireEmployee(): returns void
this.employees.set(employee.getEmployeeId(), employee);
// => Delegates to internal method
// => Add employee to HR system
console.log("Employee hired in HR system");
// => Outputs result
// => HR-specific process (no external context notified)
}
}
}
// Context 2 - Customer Support Ticketing (completely independent)
namespace CustomerSupportContext {
// => CustomerSupportContext: context boundary namespace
export class SupportAgent {
// => Support's Agent model (different from HR Employee!)
private readonly agentId: string; // => Support identifier
private readonly displayName: string; // => Customer-facing name
private readonly skillSet: string[]; // => Support categories
private readonly activeTickets: number = 0; // => Current workload
constructor(agentId: string, displayName: string, skillSet: string[]) {
// => Constructor: initializes new instance
this.agentId = agentId; // => Initialize agent ID
this.displayName = displayName; // => Initialize display name
this.skillSet = skillSet; // => Initialize skills
}
assignTicket(ticket: SupportTicket): void {
// => Support operation: ticket assignment
// Note: No integration with HR context
console.log(`Ticket assigned to ${this.displayName}`);
// => Delegates to internal method
// => Outputs result
// => Support-specific business logic
}
getAgentId(): string {
// => getAgentId(): returns string
return this.agentId; // => Expose agent ID
}
}
export class SupportTicket {
constructor(
private readonly ticketId: string,
// => ticketId: private readonly string field
private readonly description: string,
// => description: private readonly string field
) {}
// => Constructor body empty: no additional initialization needed
getTicketId(): string {
// => getTicketId(): returns string
return this.ticketId; // => Expose ticket ID
}
}
export class SupportService {
private agents: Map<string, SupportAgent> = new Map();
// => Encapsulated field (not publicly accessible)
// => Support agent records
registerAgent(agent: SupportAgent): void {
// => registerAgent(): returns void
this.agents.set(agent.getAgentId(), agent);
// => Delegates to internal method
// => Add agent to Support system
console.log("Agent registered in Support system");
// => Outputs result
// => Support-specific process (no HR context notified)
}
}
}
// Usage - Separate Ways: No integration between contexts
const hrService = new HRContext.HRService();
// => HR context service
const employee = new HRContext.Employee("E123", "Alice Johnson", "Engineering", 120000, new Date("2024-01-15"));
// => HR Employee entity
hrService.hireEmployee(employee);
// => Output: Employee hired in HR system
// => Note: No notification to Support context
const supportService = new CustomerSupportContext.SupportService();
// => Support context service (independent)
const agent = new CustomerSupportContext.SupportAgent("A456", "Alice J.", ["Technical", "Billing"]);
// => Support Agent entity (unrelated to HR Employee)
supportService.registerAgent(agent);
// => Output: Agent registered in Support system
// => Note: No notification to HR context
// These contexts operate independently - no shared data, no integration
console.log("Contexts operate separately with no integration");
// => Outputs resultKey Takeaway: Separate Ways acknowledges that integration isn't always necessary or valuable. When two contexts have no business reason to communicate, forcing integration creates unnecessary coupling and complexity. Let them evolve independently.
Why It Matters: Not every context needs integration. A media platform's HR system and their Content Recommendation engine have zero integration points—HR hires people, Recommendation suggests videos, and these domains don't overlap. Forcing integration (e.g., "recommend employee training based on viewing habits") would create artificial coupling for negligible value. Separate Ways saves development cost by explicitly documenting "no integration needed," preventing future teams from wasting time on unnecessary integration projects.
Example 41: Partnership Pattern
Two teams with mutual dependency commit to coordinating their development to support each other's needs.
// Context 1 - Order Management (Partner A)
namespace OrderManagementContext {
export class Order {
constructor(
// => Initialize object with parameters
private readonly orderId: string,
// => Encapsulated state, not directly accessible
private readonly customerId: string,
// => Encapsulated state, not directly accessible
private readonly items: OrderItem[],
// => Encapsulated state, not directly accessible
private status: OrderStatus = "PENDING",
// => Encapsulated field (not publicly accessible)
) {}
confirm(): void {
if (this.status !== "PENDING") {
// => Validate status
throw new Error("Order already processed");
// => Raise domain exception
}
this.status = "CONFIRMED"; // => Update status
// => Order confirmed, ready for payment
}
// => Validates business rule
getOrderId(): string {
return this.orderId; // => Expose order ID
// => Validates business rule
}
// => Enforces invariant
getStatus(): OrderStatus {
return this.status; // => Expose status
// => Enforces invariant
}
getTotalAmount(): number {
return this.items.reduce((sum, item) => sum + item.getTotal(), 0);
// => Calculate total amount
}
}
export class OrderItem {
constructor(
// => Initialize object with parameters
private readonly productId: string,
// => Encapsulated state, not directly accessible
private readonly quantity: number,
// => Encapsulated state, not directly accessible
private readonly price: number,
// => Encapsulated state, not directly accessible
) {}
getTotal(): number {
return this.quantity * this.price; // => Line item total
}
}
export type OrderStatus = "PENDING" | "CONFIRMED" | "PAID" | "SHIPPED";
// => Invariant validation executed
// Partnership coordination method - supports PaymentContext
export interface OrderService {
getOrderForPayment(orderId: string): OrderPaymentDetails;
// => Method designed in partnership with Payment team
markOrderAsPaid(orderId: string): void;
// => Callback method for Payment team to invoke
}
export interface OrderPaymentDetails {
orderId: string; // => Order identifier
customerId: string; // => Customer reference
amount: number; // => Amount to charge
currency: string; // => Currency code
}
}
// => Communicates domain intent
// Context 2 - Payment Processing (Partner B)
namespace PaymentContext {
import OrderPaymentDetails = OrderManagementContext.OrderPaymentDetails;
// => Import partner's contract
export class Payment {
constructor(
// => Initialize object with parameters
private readonly paymentId: string,
// => Encapsulated state, not directly accessible
private readonly orderId: string,
// => Encapsulated state, not directly accessible
private readonly amount: number,
// => Encapsulated state, not directly accessible
private status: PaymentStatus = "PENDING",
// => Encapsulated field (not publicly accessible)
) {}
process(): void {
if (this.status !== "PENDING") {
// => Validate status
throw new Error("Payment already processed");
// => Raise domain exception
}
this.status = "COMPLETED"; // => Update status
// => Payment processed successfully
}
// => Validates business rule
getStatus(): PaymentStatus {
return this.status; // => Expose status
}
// => Enforces invariant
getOrderId(): string {
return this.orderId; // => Expose order ID
}
}
export type PaymentStatus = "PENDING" | "COMPLETED" | "FAILED";
// => Aggregate boundary enforced here
// Partnership coordination - designed with Order team
export interface PaymentService {
processOrderPayment(paymentDetails: OrderPaymentDetails): Payment;
// => Method uses Order team's contract
}
}
// Partnership implementation - coordinated development
class PartnershipCoordinator {
constructor(
// => Initialize object with parameters
private orderRepository: Map<string, OrderManagementContext.Order>,
// => Encapsulated field (not publicly accessible)
private paymentRepository: Map<string, PaymentContext.Payment>,
// => Encapsulated field (not publicly accessible)
) {}
// Workflow coordinated between both teams
processOrderWithPayment(orderId: string): void {
// => Partnership workflow
const order = this.orderRepository.get(orderId);
// => Retrieve order
if (!order) {
throw new Error("Order not found");
// => Raise domain exception
}
// Step 1: Order team confirms order
order.confirm();
// => Order status: PENDING → CONFIRMED
// Step 2: Create payment details (agreed interface)
const paymentDetails: OrderManagementContext.OrderPaymentDetails = {
// => Create data structure
orderId: order.getOrderId(),
// => Execute method
customerId: "CUST-123",
// => Entity state transition managed
amount: order.getTotalAmount(),
// => Execute method
currency: "USD",
// => Domain model consistency maintained
};
// => Payment details extracted from order
// Step 3: Payment team processes payment
const payment = new PaymentContext.Payment(`PAY-${Date.now()}`, paymentDetails.orderId, paymentDetails.amount);
// => Payment created
payment.process();
// => Payment processed
this.paymentRepository.set(payment.getOrderId(), payment);
// => Delegates to internal method
// => Payment stored
console.log(`Partnership workflow: Order ${orderId} confirmed and paid`);
// => Outputs result
// => Both contexts coordinated successfully
}
// => Communicates domain intent
}
// Usage - Partnership pattern in action
const orderRepo = new Map<string, OrderManagementContext.Order>();
// => Store value in orderRepo
const paymentRepo = new Map<string, PaymentContext.Payment>();
// => Store value in paymentRepo
const orderItem = new OrderManagementContext.OrderItem("P1", 2, 5000);
// => orderItem: quantity=2, price=5000
const order = new OrderManagementContext.Order("O123", "C456", [orderItem]);
// => order: totalAmount=10000, status="PENDING"
orderRepo.set("O123", order);
// => Execute method
const coordinator = new PartnershipCoordinator(orderRepo, paymentRepo);
// => Coordinator manages partnership workflow
coordinator.processOrderWithPayment("O123");
// => Output: Partnership workflow: Order O123 confirmed and paidKey Takeaway: Partnership pattern formalizes mutual dependency between two contexts. Both teams coordinate development schedules, share interface designs, and commit to supporting each other's needs. Use when success of both contexts depends on tight integration and neither dominates the relationship.
Why It Matters: Partnership enables collaborative innovation when contexts need deep integration. A payment platform's Payment and Fraud Detection contexts operate as partners—Fraud needs real-time payment data, Payments need immediate fraud verdicts. Both teams meet regularly to coordinate API changes, release schedules, and feature roadmaps. This partnership significantly reduced payment fraud while maintaining low payment latency. Partnership works when both teams have equal leverage and mutual dependency—otherwise, use Customer-Supplier pattern.
Example 42: Big Ball of Mud Pattern (Anti-Pattern Recognition)
Recognizing when no clear boundaries exist and refactoring toward proper Bounded Contexts.
// ANTI-PATTERN: Big Ball of Mud - No clear context boundaries
class GodClass {
// => Single class mixing multiple domain concerns
private customerId: string; // => Sales concern
private orderHistory: any[] = []; // => Order concern
private shippingAddress: string; // => Shipping concern
private creditLimit: number; // => Finance concern
private supportTickets: any[] = []; // => Support concern
// => Communicates domain intent
private loyaltyPoints: number; // => Marketing concern
// Sales method
placeOrder(orderId: string, amount: number): void {
// => Sales logic mixed with everything else
if (amount > this.creditLimit) {
throw new Error("Credit limit exceeded");
// => Raise domain exception
}
this.orderHistory.push({ orderId, amount });
// => Delegates to internal method
this.loyaltyPoints += Math.floor(amount / 100);
// => Modifies loyaltyPoints
// => State change operation
// => Multiple concerns entangled
}
// Support method
createTicket(issue: string): void {
// => Support logic mixed in
this.supportTickets.push({ issue, date: new Date() });
// => Delegates to internal method
}
// => Validates business rule
// Shipping method
updateAddress(newAddress: string): void {
// => Shipping logic mixed in
this.shippingAddress = newAddress;
// => Update shippingAddress state
}
// => Enforces invariant
}
// REFACTORED: Clear Bounded Contexts
namespace RefactoredSalesContext {
export class Customer {
// => Sales-specific customer model
private readonly customerId: string;
// => Encapsulated state, not directly accessible
private readonly creditLimit: number;
// => Encapsulated state, not directly accessible
private orders: string[] = [];
// => Encapsulated field (not publicly accessible)
constructor(customerId: string, creditLimit: number) {
// => Initialize object with parameters
this.customerId = customerId;
// => Update customerId state
this.creditLimit = creditLimit;
// => Update creditLimit state
}
placeOrder(orderId: string, amount: number): void {
// => Pure sales business logic
if (amount > this.creditLimit) {
throw new Error("Credit limit exceeded");
// => Raise domain exception
}
this.orders.push(orderId);
// => Delegates to internal method
// => Sales concern isolated
}
getCustomerId(): string {
return this.customerId;
// => Return result to caller
}
}
}
namespace RefactoredShippingContext {
export class DeliveryProfile {
// => Shipping-specific model
private readonly customerId: string;
// => Encapsulated state, not directly accessible
private shippingAddress: string;
// => Encapsulated field (not publicly accessible)
constructor(customerId: string, shippingAddress: string) {
// => Initialize object with parameters
this.customerId = customerId;
// => Update customerId state
this.shippingAddress = shippingAddress;
// => Update shippingAddress state
}
updateAddress(newAddress: string): void {
// => Pure shipping business logic
this.shippingAddress = newAddress;
// => Shipping concern isolated
}
// => Communicates domain intent
getAddress(): string {
return this.shippingAddress;
// => Return result to caller
}
}
}
// => Validates business rule
namespace RefactoredSupportContext {
// => Enforces invariant
export class CustomerAccount {
// => Support-specific model
private readonly customerId: string;
// => Encapsulated state, not directly accessible
private tickets: Ticket[] = [];
// => Encapsulated field (not publicly accessible)
constructor(customerId: string) {
// => Initialize object with parameters
this.customerId = customerId;
// => Update customerId state
}
createTicket(issue: string): Ticket {
// => Pure support business logic
const ticket = new Ticket(issue, new Date());
// => Store value in ticket
this.tickets.push(ticket);
// => Delegates to internal method
return ticket;
// => Support concern isolated
}
}
class Ticket {
constructor(
// => Initialize object with parameters
public readonly issue: string,
public readonly createdAt: Date,
) {}
}
}
// Usage - Proper context boundaries
const salesCustomer = new RefactoredSalesContext.Customer("C123", 10000);
// => Sales context: focus on credit and orders
salesCustomer.placeOrder("O456", 5000);
// => Execute method
const deliveryProfile = new RefactoredShippingContext.DeliveryProfile("C123", "123 Main St");
// => Shipping context: focus on delivery logistics
deliveryProfile.updateAddress("456 Oak Ave");
// => Execute method
const supportAccount = new RefactoredSupportContext.CustomerAccount("C123");
// => Support context: focus on customer issues
supportAccount.createTicket("Product defect");
// => Execute method
console.log("Contexts properly separated with clear boundaries");
// => Outputs resultKey Takeaway: Big Ball of Mud occurs when no Bounded Contexts exist—all domain concepts tangled in shared classes. Refactoring into Bounded Contexts separates concerns, enabling independent evolution and clearer domain models. Recognize the anti-pattern by spotting classes mixing unrelated business rules.
Why It Matters: Big Ball of Mud is the default state without DDD. A marketplace platform's initial codebase had a single "User" class with many fields serving Hosts, Guests, Payment, Support, and Marketing. Refactoring into context-specific models (Host, Guest, PaymentAccount, SupportCase, MarketingProfile) significantly reduced the User class complexity into multiple focused classes. This separation enabled independent teams to work simultaneously without merge conflicts, accelerating feature delivery.
Application Services (Examples 43-47)
Example 43: Application Service - Orchestrating Use Cases
Application Services coordinate domain objects to fulfill use cases. They're transaction boundaries that delegate business logic to domain entities while managing infrastructure concerns.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
sequenceDiagram
participant UI as UI/Controller
participant AS as Application Service
participant Repo as Repository
participant Agg as Order (Aggregate)
participant EB as Event Bus
UI->>AS: placeOrder(command)
AS->>Repo: findCustomer(id)
AS->>Agg: place(items)
Agg-->>AS: OrderPlaced event
AS->>Repo: save(order)
AS->>EB: publish(event)
AS-->>UI: result
// Domain Layer - Rich domain model with business logic
class BankAccount {
private constructor(
// => Initialize object with parameters
private readonly accountId: string,
// => Encapsulated state, not directly accessible
private balance: number,
// => Encapsulated field (not publicly accessible)
private readonly overdraftLimit: number,
// => Encapsulated state, not directly accessible
) {}
static create(accountId: string, initialDeposit: number, overdraftLimit: number): BankAccount {
// => Factory method with business rules
if (initialDeposit < 0) {
throw new Error("Initial deposit cannot be negative");
// => Raise domain exception
}
return new BankAccount(accountId, initialDeposit, overdraftLimit);
// => Create new account with validated initial state
}
// => Validates business rule
withdraw(amount: number): void {
// => Domain business logic
if (amount <= 0) {
throw new Error("Withdrawal amount must be positive");
// => Raise domain exception
}
// => Enforces invariant
const availableBalance = this.balance + this.overdraftLimit;
// => Store value in availableBalance
if (amount > availableBalance) {
throw new Error("Insufficient funds including overdraft");
// => Raise domain exception
}
this.balance -= amount;
// => State change operation
// => Modifies state value
// => Balance updated
// => Balance updated, business rules enforced
}
deposit(amount: number): void {
// => Domain business logic
if (amount <= 0) {
throw new Error("Deposit amount must be positive");
// => Raise domain exception
}
this.balance += amount;
// => State change operation
// => Modifies state value
// => Balance updated
// => Balance updated
}
getBalance(): number {
return this.balance;
// => Return result to caller
}
getAccountId(): string {
return this.accountId;
// => Return result to caller
}
}
// Repository Interface (Domain Layer)
interface BankAccountRepository {
// => BankAccountRepository: contract definition
findById(accountId: string): BankAccount | null;
save(account: BankAccount): void;
}
// Application Service - Orchestrates use case
class TransferApplicationService {
// => Application layer: coordinates domain objects
constructor(private readonly accountRepository: BankAccountRepository) {}
// => Initialize object with parameters
transferMoney(fromAccountId: string, toAccountId: string, amount: number): void {
// => Use case: transfer money between accounts
// Step 1: Load aggregates
const fromAccount = this.accountRepository.findById(fromAccountId);
// => Store value in fromAccount
const toAccount = this.accountRepository.findById(toAccountId);
// => Store value in toAccount
if (!fromAccount || !toAccount) {
throw new Error("Account not found");
// => Raise domain exception
}
// Step 2: Execute domain logic (business rules in domain)
fromAccount.withdraw(amount);
// => Domain enforces withdrawal rules
toAccount.deposit(amount);
// => Domain enforces deposit rules
// Step 3: Persist changes (transaction boundary)
this.accountRepository.save(fromAccount);
// => Delegates to internal method
// => Save updated source account
this.accountRepository.save(toAccount);
// => Delegates to internal method
// => Save updated target account
// => Application Service coordinates, domain objects enforce rules
}
}
// => Communicates domain intent
// Infrastructure Layer - Repository implementation
class InMemoryBankAccountRepository implements BankAccountRepository {
private accounts: Map<string, BankAccount> = new Map();
// => Encapsulated field (not publicly accessible)
findById(accountId: string): BankAccount | null {
return this.accounts.get(accountId) || null;
// => Retrieve account
}
save(account: BankAccount): void {
this.accounts.set(account.getAccountId(), account);
// => Delegates to internal method
// => Persist account
}
}
// => Validates business rule
// Usage - Application Service orchestrates use case
const repository = new InMemoryBankAccountRepository();
// => Infrastructure dependency
const account1 = BankAccount.create("ACC-001", 1000, 500);
// => account1: balance=1000, overdraftLimit=500
repository.save(account1);
// => Execute method
const account2 = BankAccount.create("ACC-002", 500, 0);
// => account2: balance=500, overdraftLimit=0
repository.save(account2);
// => Execute method
const transferService = new TransferApplicationService(repository);
// => Application Service instantiated
transferService.transferMoney("ACC-001", "ACC-002", 300);
// => Orchestrates: withdraw from ACC-001, deposit to ACC-002
console.log(`ACC-001 balance: ${account1.getBalance()}`);
// => Outputs result
// => Output: ACC-001 balance: 700
console.log(`ACC-002 balance: ${account2.getBalance()}`);
// => Outputs result
// => Output: ACC-002 balance: 800Key Takeaway: Application Services orchestrate use cases by coordinating domain objects, managing transactions, and handling infrastructure. Business logic stays in domain entities; Application Services delegate to domain objects rather than implementing business rules themselves.
Why It Matters: Application Services prevent anemic domain models. When Square refactored their payment processing, they moved business logic from Application Services into Payment, Merchant, and Transaction domain entities. Application Services became thin orchestration layers handling transactions, logging, and event publishing—while domain entities enforced business rules like "refund can't exceed original payment." This separation enabled domain logic reuse across multiple use cases (web API, mobile app, batch processing) without duplicating business rules.
Example 44: Application Service with Domain Events
Application Services publish domain events after successful use case completion, enabling decoupled communication.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Application Service\nuse case coordinator"]
B["Domain Aggregate\nbusiness logic"]
C["Event Publisher\npublishes after commit"]
D["Email Service\nhandler"]
E["Audit Service\nhandler"]
A -->|orchestrates| B
B -->|raises| C
A -->|publishes after tx| C
C -->|notifies| D
C -->|notifies| E
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#029E73,stroke:#000,color:#fff
style C fill:#CC78BC,stroke:#000,color:#fff
style D fill:#DE8F05,stroke:#000,color:#000
style E fill:#DE8F05,stroke:#000,color:#000
// Domain Event
class OrderPlacedEvent {
// => Domain event representing business-significant occurrence
constructor(
// => Initialize object with parameters
public readonly orderId: string,
public readonly customerId: string,
public readonly totalAmount: number,
public readonly occurredAt: Date,
) {}
}
// Domain Model
class Order {
private events: OrderPlacedEvent[] = [];
// => Encapsulated field (not publicly accessible)
// => Collect domain events
constructor(
// => Initialize object with parameters
private readonly orderId: string,
// => Encapsulated state, not directly accessible
private readonly customerId: string,
// => Encapsulated state, not directly accessible
private readonly items: OrderItem[],
// => Encapsulated state, not directly accessible
private status: OrderStatus = "DRAFT",
// => Encapsulated field (not publicly accessible)
) {}
// => Validates business rule
place(): void {
// => Domain business logic
if (this.status !== "DRAFT") {
throw new Error("Order already placed");
// => Raise domain exception
}
// => Enforces invariant
this.status = "PLACED";
// => Status updated
// Raise domain event
this.events.push(new OrderPlacedEvent(this.orderId, this.customerId, this.getTotalAmount(), new Date()));
// => Delegates to internal method
// => Event recorded for later publication
}
getEvents(): OrderPlacedEvent[] {
return [...this.events];
// => Expose collected events
}
clearEvents(): void {
this.events = [];
// => Clear events after publication
}
private getTotalAmount(): number {
// => Internal logic (not part of public API)
return this.items.reduce((sum, item) => sum + item.price, 0);
// => Return result to caller
}
getOrderId(): string {
return this.orderId;
// => Return result to caller
}
}
class OrderItem {
constructor(
// => Initialize object with parameters
public readonly productId: string,
public readonly price: number,
) {}
}
type OrderStatus = "DRAFT" | "PLACED" | "SHIPPED";
// => Entity state transition managed
// Event Publisher Interface
interface EventPublisher {
// => EventPublisher: contract definition
publish(event: OrderPlacedEvent): void;
}
// Application Service - Publishes events after transaction
class PlaceOrderApplicationService {
constructor(
// => Initialize object with parameters
private readonly orderRepository: OrderRepository,
// => Encapsulated state, not directly accessible
private readonly eventPublisher: EventPublisher,
// => Encapsulated state, not directly accessible
) {}
// => Communicates domain intent
placeOrder(orderId: string): void {
// => Use case: place order
const order = this.orderRepository.findById(orderId);
// => Store value in order
if (!order) {
throw new Error("Order not found");
// => Raise domain exception
}
// Execute domain logic
order.place();
// => Domain raises event internally
// Persist changes
this.orderRepository.save(order);
// => Delegates to internal method
// => Save order state
// Publish domain events (after transaction succeeds)
const events = order.getEvents();
// => Store value in events
events.forEach((event) => this.eventPublisher.publish(event));
// => Publish events to external subscribers
order.clearEvents();
// => Clear events after publication
}
}
// => Validates business rule
interface OrderRepository {
// => OrderRepository: contract definition
findById(orderId: string): Order | null;
save(order: Order): void;
}
// => Enforces invariant
// Infrastructure - Simple event publisher
class InMemoryEventPublisher implements EventPublisher {
private publishedEvents: OrderPlacedEvent[] = [];
// => Encapsulated field (not publicly accessible)
publish(event: OrderPlacedEvent): void {
this.publishedEvents.push(event);
// => Delegates to internal method
// => Record published event
console.log(`Event published: OrderPlaced for ${event.orderId}`);
// => Outputs result
}
getPublishedEvents(): OrderPlacedEvent[] {
return this.publishedEvents;
// => Return result to caller
}
}
class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map();
// => Encapsulated field (not publicly accessible)
findById(orderId: string): Order | null {
return this.orders.get(orderId) || null;
// => Return result to caller
}
save(order: Order): void {
this.orders.set(order.getOrderId(), order);
// => Delegates to internal method
}
}
// Usage - Application Service publishes events
const orderRepo = new InMemoryOrderRepository();
// => Store value in orderRepo
const eventPublisher = new InMemoryEventPublisher();
// => Store value in eventPublisher
const order = new Order("O123", "C456", [new OrderItem("P1", 5000), new OrderItem("P2", 3000)]);
// => order: totalAmount=8000, status="DRAFT"
orderRepo.save(order);
// => Execute method
const placeOrderService = new PlaceOrderApplicationService(orderRepo, eventPublisher);
// => Application Service with event publishing
placeOrderService.placeOrder("O123");
// => Output: Event published: OrderPlaced for O123
console.log(`Events published: ${eventPublisher.getPublishedEvents().length}`);
// => Outputs result
// => Output: Events published: 1Key Takeaway: Application Services collect domain events from aggregates and publish them after successful transaction completion. Domain objects raise events internally; Application Services handle infrastructure concerns (event publishing, transaction management). This maintains clean separation between domain logic and infrastructure.
Why It Matters: Event publishing at Application Service layer ensures consistency. A ride-sharing platform's Trip domain raises TripCompleted event when driver marks trip finished. The Application Service saves trip state, publishes event (triggering payment processing, driver rating prompt, receipt email), and only then commits the transaction. If any step fails, entire operation rolls back—preventing split-brain scenarios where trip marked complete but payment never processed. Application Services as transaction boundaries with event publication ensure atomic state changes plus reliable side effects.
Example 45: Application Service - Input Validation
Application Services validate input from external sources (API, CLI) before invoking domain logic.
// Input DTO from external source (API, CLI, etc.)
interface CreateProductRequest {
name: string | null; // => May be null from external source
price: number | null; // => May be null
category: string | null; // => May be null
}
// Domain Model - assumes valid input
class Product {
private constructor(
// => Initialize object with parameters
private readonly productId: string,
// => Encapsulated state, not directly accessible
private readonly name: string,
// => Encapsulated state, not directly accessible
private readonly price: number,
// => Encapsulated state, not directly accessible
private readonly category: string,
// => Encapsulated state, not directly accessible
) {}
static create(productId: string, name: string, price: number, category: string): Product {
// => Domain factory assumes valid input (validation already done)
return new Product(productId, name, price, category);
// => Return result to caller
}
// => Validates business rule
getProductId(): string {
return this.productId;
// => Return result to caller
}
// => Enforces invariant
}
interface ProductRepository {
// => ProductRepository: contract definition
save(product: Product): void;
}
// Application Service - validates external input
class CreateProductApplicationService {
constructor(private readonly productRepository: ProductRepository) {}
// => Initialize object with parameters
createProduct(request: CreateProductRequest): string {
// => Use case with input validation
// Step 1: Validate input (Application Service responsibility)
this.validateRequest(request);
// => Delegates to internal method
// => Throws if invalid, prevents bad data reaching domain
// Step 2: Ensure non-null after validation
const name = request.name!;
// => Store value in name
const price = request.price!;
// => Store value in price
const category = request.category!;
// => Store value in category
// Step 3: Invoke domain logic (assumes valid input)
const productId = `PROD-${Date.now()}`;
// => Store value in productId
const product = Product.create(productId, name, price, category);
// => Domain creates product with valid data
// Step 4: Persist
this.productRepository.save(product);
// => Delegates to internal method
// => Save to repository
return productId; // => Return identifier
}
private validateRequest(request: CreateProductRequest): void {
// => Internal logic (not part of public API)
// => Input validation rules (Application layer concern)
const errors: string[] = [];
// => Create data structure
if (!request.name || request.name.trim().length === 0) {
// => Conditional check
errors.push("Name is required");
// => Execute method
}
if (request.name && request.name.length > 100) {
errors.push("Name cannot exceed 100 characters");
// => Execute method
}
if (request.price === null || request.price === undefined) {
errors.push("Price is required");
// => Execute method
}
if (request.price !== null && request.price <= 0) {
errors.push("Price must be positive");
// => Execute method
}
if (!request.category || request.category.trim().length === 0) {
// => Conditional check
errors.push("Category is required");
// => Execute method
}
if (errors.length > 0) {
// => Validation failed
throw new Error(`Validation failed: ${errors.join(", ")}`);
// => Raise domain exception
}
// => Validation passed
}
}
// Infrastructure
class InMemoryProductRepository implements ProductRepository {
private products: Map<string, Product> = new Map();
// => Encapsulated field (not publicly accessible)
save(product: Product): void {
this.products.set(product.getProductId(), product);
// => Delegates to internal method
console.log(`Product saved: ${product.getProductId()}`);
// => Outputs result
}
// => Communicates domain intent
}
// Usage - Application Service validates input
const productRepo = new InMemoryProductRepository();
// => Store value in productRepo
const createProductService = new CreateProductApplicationService(productRepo);
// => Store value in createProductService
// Valid request
const validRequest: CreateProductRequest = {
// => Create data structure
name: "Laptop",
// => Modifies aggregate internal state
price: 1200,
// => Validates business rule
category: "Electronics",
// => Enforces invariant
};
const productId = createProductService.createProduct(validRequest);
// => Output: Product saved: PROD-[timestamp]
console.log(`Created product: ${productId}`);
// => Outputs result
// Invalid request - caught by validation
try {
const invalidRequest: CreateProductRequest = {
// => Communicates domain intent
name: null, // => Invalid: name required
price: -100, // => Invalid: price must be positive
category: "",
// => Aggregate boundary enforced here
};
createProductService.createProduct(invalidRequest);
// => Execute method
} catch (error) {
// => Cross-context interaction point
console.log(`Validation error: ${error.message}`);
// => Outputs result
// => Output: Validation error: Validation failed: Name is required, Price must be positive, Category is required
}Key Takeaway: Application Services validate external input before invoking domain logic. This separates infrastructure concerns (parsing, type coercion, null checks) from domain concerns (business rules). Domain objects can assume inputs are valid, making domain code cleaner and more focused on business logic.
Why It Matters: Input validation at Application Service layer prevents defensive programming in domain layer. A payment platform's Charge domain entity doesn't check for null amounts or negative values—that's validated in CreateChargeApplicationService before reaching domain. This separation enables reusing Charge entity across REST API, GraphQL API, and internal admin tools, each with different input formats but same domain rules. Application Services adapt external inputs to domain requirements, keeping domain pure.
Example 46: Application Service - Cross-Aggregate Transactions
Coordinating multiple aggregates within a transaction while respecting aggregate boundaries.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
sequenceDiagram
participant AS as Application Service
participant OA as Order Aggregate
participant IA as Inventory Aggregate
participant DB as Database
AS->>OA: place(items)
OA-->>AS: OrderPlaced event
AS->>IA: reserve(items)
IA-->>AS: InventoryReserved event
AS->>DB: commit transaction
DB-->>AS: success
// Aggregate 1 - Customer
class Customer {
private constructor(
// => Initialize object with parameters
private readonly customerId: string,
// => Encapsulated state, not directly accessible
private loyaltyPoints: number,
// => Encapsulated field (not publicly accessible)
) {}
static create(customerId: string, initialPoints: number): Customer {
return new Customer(customerId, initialPoints);
// => Return result to caller
}
addLoyaltyPoints(points: number): void {
// => Domain logic: earn loyalty points
if (points < 0) {
throw new Error("Points must be positive");
// => Raise domain exception
}
// => Validates business rule
this.loyaltyPoints += points;
// => Modifies loyaltyPoints
// => State change operation
// => Modifies state value
// => Points added
}
// => Enforces invariant
getCustomerId(): string {
return this.customerId;
// => Return result to caller
}
getLoyaltyPoints(): number {
return this.loyaltyPoints;
// => Return result to caller
}
}
// Aggregate 2 - Order
class Order {
private constructor(
// => Initialize object with parameters
private readonly orderId: string,
// => Encapsulated state, not directly accessible
private readonly customerId: string,
// => Encapsulated state, not directly accessible
private readonly totalAmount: number,
// => Encapsulated state, not directly accessible
private status: OrderStatus = "PENDING",
// => Encapsulated field (not publicly accessible)
) {}
static create(orderId: string, customerId: string, totalAmount: number): Order {
if (totalAmount <= 0) {
throw new Error("Total amount must be positive");
// => Raise domain exception
}
return new Order(orderId, customerId, totalAmount);
// => Return result to caller
}
confirm(): void {
// => Domain logic: confirm order
if (this.status !== "PENDING") {
throw new Error("Order already processed");
// => Raise domain exception
}
this.status = "CONFIRMED";
// => Order confirmed
}
getTotalAmount(): number {
return this.totalAmount;
// => Return result to caller
}
getCustomerId(): string {
return this.customerId;
// => Return result to caller
}
getOrderId(): string {
return this.orderId;
// => Return result to caller
}
// => Communicates domain intent
}
type OrderStatus = "PENDING" | "CONFIRMED" | "SHIPPED";
// => Modifies aggregate internal state
interface CustomerRepository {
// => CustomerRepository: contract definition
findById(customerId: string): Customer | null;
save(customer: Customer): void;
}
// => Validates business rule
interface OrderRepository {
// => OrderRepository: contract definition
save(order: Order): void;
}
// => Enforces invariant
// Application Service - coordinates multiple aggregates
class PlaceOrderWithLoyaltyService {
constructor(
// => Initialize object with parameters
private readonly customerRepo: CustomerRepository,
// => Encapsulated state, not directly accessible
private readonly orderRepo: OrderRepository,
// => Encapsulated state, not directly accessible
) {}
placeOrder(customerId: string, totalAmount: number): string {
// => Use case: place order and award loyalty points
// Step 1: Create order aggregate
const orderId = `ORDER-${Date.now()}`;
// => Store value in orderId
const order = Order.create(orderId, customerId, totalAmount);
// => Order aggregate created
// Step 2: Confirm order (within Order aggregate)
order.confirm();
// => Order confirmed
// Step 3: Calculate loyalty points (business rule)
const loyaltyPoints = Math.floor(totalAmount / 100);
// => 1 point per $100 spent
// Step 4: Load Customer aggregate
const customer = this.customerRepo.findById(customerId);
// => Store value in customer
if (!customer) {
throw new Error("Customer not found");
// => Raise domain exception
}
// Step 5: Award points (within Customer aggregate)
customer.addLoyaltyPoints(loyaltyPoints);
// => Points added to customer
// Step 6: Persist both aggregates (transaction boundary)
this.orderRepo.save(order);
// => Delegates to internal method
// => Save order
this.customerRepo.save(customer);
// => Delegates to internal method
// => Save customer
// => Both aggregates updated atomically
return orderId;
}
}
// Infrastructure
class InMemoryCustomerRepository implements CustomerRepository {
private customers: Map<string, Customer> = new Map();
// => Encapsulated field (not publicly accessible)
findById(customerId: string): Customer | null {
return this.customers.get(customerId) || null;
// => Return result to caller
}
save(customer: Customer): void {
this.customers.set(customer.getCustomerId(), customer);
// => Delegates to internal method
}
}
class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map();
// => Encapsulated field (not publicly accessible)
save(order: Order): void {
this.orders.set(order.getOrderId(), order);
// => Delegates to internal method
}
}
// Usage - Application Service coordinates multiple aggregates
const customerRepo = new InMemoryCustomerRepository();
// => Store value in customerRepo
const orderRepo = new InMemoryOrderRepository();
// => Store value in orderRepo
const customer = Customer.create("C123", 100);
// => customer: loyaltyPoints=100
customerRepo.save(customer);
// => Execute method
const placeOrderService = new PlaceOrderWithLoyaltyService(customerRepo, orderRepo);
// => Application Service coordinates Order and Customer
const orderId = placeOrderService.placeOrder("C123", 5000);
// => Creates Order, awards 50 loyalty points to Customer
console.log(`Order placed: ${orderId}`);
// => Outputs result
console.log(`Customer loyalty points: ${customer.getLoyaltyPoints()}`);
// => Outputs result
// => Output: Customer loyalty points: 150Key Takeaway: Application Services coordinate multiple aggregates within a single transaction, respecting aggregate boundaries. Each aggregate enforces its own invariants; Application Service manages the transaction scope ensuring all changes commit together or roll back together.
Why It Matters: Cross-aggregate transactions are unavoidable in real systems, but must be used carefully. An e-commerce platform's OrderPlacement service updates Order, Customer, and Inventory aggregates atomically—preventing oversold inventory or lost loyalty points. However, cross-aggregate transactions reduce scalability (locks on multiple aggregates) and should be minimized. Eventual consistency (domain events + separate transactions) is preferred for loosely coupled aggregates, but immediate consistency via Application Service transactions is necessary when business invariants span aggregates.
Example 47: Application Service - Error Handling and Compensation
Application Services handle failure scenarios gracefully, implementing compensation logic when partial operations fail.
// Domain entities
class Reservation {
constructor(
// => Initialize object with parameters
private readonly reservationId: string,
// => Encapsulated state, not directly accessible
private readonly customerId: string,
// => Encapsulated state, not directly accessible
private status: ReservationStatus = "ACTIVE",
// => Encapsulated field (not publicly accessible)
) {}
cancel(): void {
if (this.status !== "ACTIVE") {
throw new Error("Reservation already cancelled");
// => Raise domain exception
}
this.status = "CANCELLED";
// => Update status state
}
// => Validates business rule
getReservationId(): string {
return this.reservationId;
// => Return result to caller
}
// => Enforces invariant
getCustomerId(): string {
return this.customerId;
// => Return result to caller
}
}
type ReservationStatus = "ACTIVE" | "CANCELLED";
// => Aggregate boundary enforced here
class PaymentRecord {
constructor(
// => Initialize object with parameters
private readonly paymentId: string,
// => Encapsulated state, not directly accessible
private readonly amount: number,
// => Encapsulated state, not directly accessible
private refunded: boolean = false,
// => Encapsulated field (not publicly accessible)
) {}
refund(): void {
if (this.refunded) {
throw new Error("Already refunded");
// => Raise domain exception
}
this.refunded = true;
// => Update refunded state
}
getPaymentId(): string {
return this.paymentId;
// => Return result to caller
}
isRefunded(): boolean {
return this.refunded;
// => Return result to caller
}
}
// Repositories
interface ReservationRepository {
// => ReservationRepository: contract definition
findById(reservationId: string): Reservation | null;
save(reservation: Reservation): void;
}
interface PaymentRepository {
// => PaymentRepository: contract definition
findByReservation(reservationId: string): PaymentRecord | null;
save(payment: PaymentRecord): void;
}
// => Communicates domain intent
// External service that may fail
interface NotificationService {
// => NotificationService: contract definition
sendCancellationEmail(customerId: string): void;
}
class UnreliableNotificationService implements NotificationService {
private shouldFail: boolean = false;
// => Encapsulated field (not publicly accessible)
setShouldFail(shouldFail: boolean): void {
this.shouldFail = shouldFail;
// => Update shouldFail state
}
sendCancellationEmail(customerId: string): void {
if (this.shouldFail) {
throw new Error("Email service unavailable");
// => Raise domain exception
}
// => Validates business rule
console.log(`Cancellation email sent to ${customerId}`);
// => Outputs result
}
// => Enforces invariant
}
// Application Service with error handling and compensation
class CancelReservationService {
constructor(
// => Initialize object with parameters
private readonly reservationRepo: ReservationRepository,
// => Encapsulated state, not directly accessible
private readonly paymentRepo: PaymentRepository,
// => Encapsulated state, not directly accessible
private readonly notificationService: NotificationService,
// => Encapsulated state, not directly accessible
) {}
cancelReservation(reservationId: string): void {
let reservation: Reservation | null = null;
// => Aggregate boundary enforced here
let payment: PaymentRecord | null = null;
// => Domain event triggered or handled
try {
// Step 1: Load reservation
reservation = this.reservationRepo.findById(reservationId);
// => DDD tactical pattern applied
if (!reservation) {
throw new Error("Reservation not found");
// => Raise domain exception
}
// Step 2: Cancel reservation
reservation.cancel();
// => Execute method
this.reservationRepo.save(reservation);
// => Delegates to internal method
// => Reservation cancelled and saved
// Step 3: Process refund
payment = this.paymentRepo.findByReservation(reservationId);
// => Transaction boundary maintained
if (payment) {
payment.refund();
// => Execute method
this.paymentRepo.save(payment);
// => Delegates to internal method
// => Refund processed and saved
}
// Step 4: Send notification (may fail)
this.notificationService.sendCancellationEmail(reservation.getCustomerId());
// => Delegates to internal method
// => Email sent successfully
} catch (error) {
// => Domain model consistency maintained
// Error occurred - implement compensation logic
console.log(`Error during cancellation: ${error.message}`);
// => Outputs result
// Compensation: If email failed but cancellation succeeded, log for retry
if (reservation && reservation.getReservationId()) {
// => Conditional check
console.log(`Reservation ${reservationId} cancelled but notification failed - queued for retry`);
// => Outputs result
// => In production: add to dead letter queue for retry
}
// => Communicates domain intent
// Re-throw if critical steps failed
if (!reservation || error.message === "Reservation not found") {
// => Guard: early return when entity not found
throw error; // => Critical failure, propagate to caller
}
// Non-critical failure (email) - log but don't fail operation
console.log("Cancellation completed despite notification failure");
// => Outputs result
}
}
// => Validates business rule
}
// => Enforces invariant
// Infrastructure
class InMemoryReservationRepository implements ReservationRepository {
private reservations: Map<string, Reservation> = new Map();
// => Encapsulated field (not publicly accessible)
findById(reservationId: string): Reservation | null {
return this.reservations.get(reservationId) || null;
// => Return result to caller
}
save(reservation: Reservation): void {
this.reservations.set(reservation.getReservationId(), reservation);
// => Delegates to internal method
}
}
class InMemoryPaymentRepository implements PaymentRepository {
private payments: Map<string, PaymentRecord> = new Map();
// => Encapsulated field (not publicly accessible)
findByReservation(reservationId: string): PaymentRecord | null {
return this.payments.get(reservationId) || null;
// => Return result to caller
}
save(payment: PaymentRecord): void {
this.payments.set(payment.getPaymentId(), payment);
// => Delegates to internal method
}
}
// Usage - Application Service handles errors gracefully
const reservationRepo = new InMemoryReservationRepository();
// => Store value in reservationRepo
const paymentRepo = new InMemoryPaymentRepository();
// => Store value in paymentRepo
const notificationService = new UnreliableNotificationService();
// => Store value in notificationService
const reservation = new Reservation("R123", "C456");
// => Store value in reservation
reservationRepo.save(reservation);
// => Execute method
const payment = new PaymentRecord("PAY-789", 10000);
// => Store value in payment
paymentRepo.save(payment);
// => Execute method
paymentRepo["payments"].set("R123", payment); // Link payment to reservation
// => Execute method
const cancelService = new CancelReservationService(reservationRepo, paymentRepo, notificationService);
// => Store value in cancelService
// Scenario 1: Successful cancellation
cancelService.cancelReservation("R123");
// => Output: Cancellation email sent to C456
// Scenario 2: Email service fails - compensation logic handles it
const reservation2 = new Reservation("R456", "C789");
// => Store value in reservation2
reservationRepo.save(reservation2);
// => Execute method
notificationService.setShouldFail(true);
// => Execute method
cancelService.cancelReservation("R456");
// => Output: Error during cancellation: Email service unavailable
// => Output: Reservation R456 cancelled but notification failed - queued for retry
// => Output: Cancellation completed despite notification failureKey Takeaway: Application Services implement error handling and compensation logic for multi-step use cases. Critical operations (cancel reservation, process refund) must succeed; non-critical operations (send email) can fail gracefully with compensation (retry queue). This ensures business operations complete even when infrastructure fails.
Why It Matters: Real systems face partial failures. When CancelBooking services fail to send email after successful cancellation, the Application Service logs the failure to a retry queue rather than rolling back the cancellation. Guests get refunded even if email servers are down; emails retry asynchronously. This separation of critical domain operations from infrastructure failures improves reliability—booking cancellations succeed even when email service experiences downtime.
Domain Event Handlers (Examples 48-52)
Example 48: Basic Domain Event Handler
Domain Event Handlers react to domain events, implementing eventual consistency and decoupled workflows.
// Domain Event
class OrderPlacedEvent {
constructor(
// => Initialize object with parameters
public readonly orderId: string,
public readonly customerId: string,
public readonly totalAmount: number,
public readonly occurredAt: Date,
) {}
}
// Event Handler Interface
interface DomainEventHandler<T> {
// => DomainEventHandler: contract definition
handle(event: T): void;
}
// => Validates business rule
// Event Handler - Send Order Confirmation Email
class SendOrderConfirmationEmailHandler implements DomainEventHandler<OrderPlacedEvent> {
constructor(private readonly emailService: EmailService) {}
// => Initialize object with parameters
handle(event: OrderPlacedEvent): void {
// => React to OrderPlaced event
console.log(`Handling OrderPlacedEvent for ${event.orderId}`);
// => Outputs result
this.emailService.sendEmail(
// => Enforces invariant
event.customerId,
// => Business rule enforced here
"Order Confirmation",
// => Execution delegated to domain service
`Your order ${event.orderId} has been placed. Total: $${event.totalAmount / 100}`,
// => Aggregate boundary enforced here
);
// => Send confirmation email
}
}
// Event Handler - Update Loyalty Points
class AwardLoyaltyPointsHandler implements DomainEventHandler<OrderPlacedEvent> {
constructor(
// => Initialize object with parameters
private readonly customerRepo: CustomerRepository,
// => Encapsulated state, not directly accessible
private readonly pointsCalculator: LoyaltyPointsCalculator,
// => Encapsulated state, not directly accessible
) {}
handle(event: OrderPlacedEvent): void {
// => React to OrderPlaced event
console.log(`Awarding loyalty points for order ${event.orderId}`);
// => Outputs result
const customer = this.customerRepo.findById(event.customerId);
// => Store value in customer
if (!customer) {
console.log("Customer not found - skipping loyalty points");
// => Outputs result
return;
// => Invariant validation executed
}
const points = this.pointsCalculator.calculate(event.totalAmount);
// => Store value in points
customer.addLoyaltyPoints(points);
// => Execute method
this.customerRepo.save(customer);
// => Delegates to internal method
// => Loyalty points awarded
console.log(`Awarded ${points} points to ${event.customerId}`);
// => Outputs result
}
}
// Supporting classes
interface EmailService {
// => EmailService: contract definition
sendEmail(recipient: string, subject: string, body: string): void;
}
// => Communicates domain intent
class MockEmailService implements EmailService {
sendEmail(recipient: string, subject: string, body: string): void {
console.log(`Email sent to ${recipient}: ${subject}`);
// => Outputs result
}
}
interface CustomerRepository {
// => CustomerRepository: contract definition
findById(customerId: string): Customer | null;
save(customer: Customer): void;
}
// => Validates business rule
class Customer {
constructor(
// => Initialize object with parameters
private readonly customerId: string,
// => Encapsulated state, not directly accessible
private loyaltyPoints: number,
// => Encapsulated field (not publicly accessible)
) {}
// => Enforces invariant
addLoyaltyPoints(points: number): void {
this.loyaltyPoints += points;
// => Modifies loyaltyPoints
// => State change operation
// => Modifies state value
}
getCustomerId(): string {
return this.customerId;
// => Return result to caller
}
getLoyaltyPoints(): number {
return this.loyaltyPoints;
// => Return result to caller
}
}
class InMemoryCustomerRepository implements CustomerRepository {
private customers: Map<string, Customer> = new Map();
// => Encapsulated field (not publicly accessible)
findById(customerId: string): Customer | null {
return this.customers.get(customerId) || null;
// => Return result to caller
}
save(customer: Customer): void {
this.customers.set(customer.getCustomerId(), customer);
// => Delegates to internal method
}
}
class LoyaltyPointsCalculator {
calculate(amount: number): number {
return Math.floor(amount / 100); // 1 point per $100
}
}
// Event Dispatcher - coordinates handlers
class EventDispatcher {
private handlers: Map<string, DomainEventHandler<any>[]> = new Map();
// => Encapsulated field (not publicly accessible)
register<T>(eventType: string, handler: DomainEventHandler<T>): void {
// => Method body begins here
if (!this.handlers.has(eventType)) {
// => Conditional check
this.handlers.set(eventType, []);
// => Delegates to internal method
}
// => Communicates domain intent
this.handlers.get(eventType)!.push(handler);
// => Delegates to internal method
// => Handler registered for event type
}
dispatch<T>(eventType: string, event: T): void {
// => Method body begins here
const handlers = this.handlers.get(eventType) || [];
// => Store value in handlers
handlers.forEach((handler) => handler.handle(event));
// => Dispatch event to all registered handlers
}
// => Validates business rule
}
// => Enforces invariant
// Usage - Multiple handlers react to single event
const customerRepo = new InMemoryCustomerRepository();
// => Store value in customerRepo
const customer = new Customer("C123", 0);
// => Store value in customer
customerRepo.save(customer);
// => Execute method
const emailService = new MockEmailService();
// => Store value in emailService
const pointsCalculator = new LoyaltyPointsCalculator();
// => Store value in pointsCalculator
const dispatcher = new EventDispatcher();
// => Store value in dispatcher
// Register handlers
dispatcher.register("OrderPlaced", new SendOrderConfirmationEmailHandler(emailService));
// => Execute method
dispatcher.register("OrderPlaced", new AwardLoyaltyPointsHandler(customerRepo, pointsCalculator));
// => Two handlers registered for OrderPlaced event
// Publish event
const event = new OrderPlacedEvent("O456", "C123", 15000, new Date());
// => Store value in event
dispatcher.dispatch("OrderPlaced", event);
// => Output: Handling OrderPlacedEvent for O456
// => Output: Email sent to C123: Order Confirmation
// => Output: Awarding loyalty points for order O456
// => Output: Awarded 150 points to C123
console.log(`Customer points: ${customer.getLoyaltyPoints()}`);
// => Outputs result
// => Output: Customer points: 150Key Takeaway: Domain Event Handlers enable decoupled reactions to business events. Multiple handlers can subscribe to the same event, each implementing independent workflows (email, loyalty, analytics). This achieves eventual consistency without tight coupling between domain operations.
Why It Matters: Event Handlers prevent tightly coupled workflows. When an e-commerce platform's OrderPlaced event fires, multiple independent handlers react: send confirmation email, update inventory, trigger fulfillment, award loyalty points, log analytics, notify warehouse, update recommendations, etc. Adding new reactions requires zero changes to Order domain—just register new handler. This extensibility enables adding new features (gift wrapping, carbon offset, fraud scoring) by adding handlers, not modifying core Order logic.
Example 49: Idempotent Event Handlers
Ensuring event handlers can safely process the same event multiple times without side effects.
// Domain Event with unique identifier
class PaymentProcessedEvent {
constructor(
// => Constructor: initializes object with provided parameters
public readonly eventId: string, // => Unique event identifier for deduplication
public readonly paymentId: string,
public readonly amount: number,
public readonly occurredAt: Date,
) {}
}
// Event Processing Record - tracks processed events
class ProcessedEvent {
constructor(
// => Initialize object with parameters
public readonly eventId: string,
public readonly processedAt: Date,
) {}
// => Validates business rule
}
// => Enforces invariant
interface ProcessedEventRepository {
// => ProcessedEventRepository: contract definition
hasBeenProcessed(eventId: string): boolean;
markAsProcessed(eventId: string): void;
}
class InMemoryProcessedEventRepository implements ProcessedEventRepository {
private processedEvents: Set<string> = new Set();
// => Encapsulated field (not publicly accessible)
hasBeenProcessed(eventId: string): boolean {
return this.processedEvents.has(eventId);
// => Check if event already processed
}
markAsProcessed(eventId: string): void {
this.processedEvents.add(eventId);
// => Delegates to internal method
// => Record event as processed
}
}
// Idempotent Event Handler
class UpdateAccountingLedgerHandler implements DomainEventHandler<PaymentProcessedEvent> {
constructor(
// => Initialize object with parameters
private readonly processedEventRepo: ProcessedEventRepository,
// => Encapsulated state, not directly accessible
private readonly ledgerService: AccountingLedgerService,
// => Encapsulated state, not directly accessible
) {}
handle(event: PaymentProcessedEvent): void {
// => Idempotent event handling
console.log(`Processing PaymentProcessedEvent ${event.eventId}`);
// => Outputs result
// Check if already processed
if (this.processedEventRepo.hasBeenProcessed(event.eventId)) {
// => Conditional check
console.log(`Event ${event.eventId} already processed - skipping`);
// => Outputs result
return; // => Skip duplicate processing
}
// Process event (first time)
this.ledgerService.recordTransaction(event.paymentId, event.amount);
// => Delegates to internal method
// => Update accounting ledger
// Mark as processed
this.processedEventRepo.markAsProcessed(event.eventId);
// => Delegates to internal method
// => Record event ID to prevent reprocessing
console.log(`Event ${event.eventId} processed successfully`);
// => Outputs result
}
}
interface AccountingLedgerService {
// => AccountingLedgerService: contract definition
recordTransaction(paymentId: string, amount: number): void;
}
class MockAccountingLedgerService implements AccountingLedgerService {
private transactions: Array<{ paymentId: string; amount: number }> = [];
// => Encapsulated field (not publicly accessible)
recordTransaction(paymentId: string, amount: number): void {
this.transactions.push({ paymentId, amount });
// => Delegates to internal method
console.log(`Ledger updated: ${paymentId} - $${amount / 100}`);
// => Outputs result
}
getTransactionCount(): number {
return this.transactions.length;
// => Return result to caller
}
// => Communicates domain intent
}
interface DomainEventHandler<T> {
// => DomainEventHandler: contract definition
handle(event: T): void;
}
// Usage - Idempotent handler prevents duplicate processing
const processedEventRepo = new InMemoryProcessedEventRepository();
// => Store value in processedEventRepo
const ledgerService = new MockAccountingLedgerService();
// => Store value in ledgerService
const handler = new UpdateAccountingLedgerHandler(processedEventRepo, ledgerService);
// => Store value in handler
const event = new PaymentProcessedEvent(
// => event: value assigned for use in this scope
"EVT-001", // => Unique event ID
"PAY-123",
// => Validates business rule
10000,
// => Enforces invariant
new Date(),
// => Business rule enforced here
);
// => Execution delegated to domain service
// Process event first time
handler.handle(event);
// => Output: Processing PaymentProcessedEvent EVT-001
// => Output: Ledger updated: PAY-123 - $100
// => Output: Event EVT-001 processed successfully
// Process same event again (duplicate delivery)
handler.handle(event);
// => Output: Processing PaymentProcessedEvent EVT-001
// => Output: Event EVT-001 already processed - skipping
console.log(`Total transactions recorded: ${ledgerService.getTransactionCount()}`);
// => Outputs result
// => Output: Total transactions recorded: 1 (not 2!)Key Takeaway: Idempotent event handlers track processed event IDs to prevent duplicate processing. Distributed systems often deliver events multiple times; idempotency ensures handlers produce same result regardless of delivery count. Check processed event registry before executing business logic.
Why It Matters: Event delivery guarantees are "at least once" not "exactly once." Kafka, RabbitMQ, and AWS SQS can deliver events multiple times during network partitions. A payment platform's RefundProcessed handler tracks processed event IDs—if message broker re-delivers refund event, handler skips duplicate processing, preventing double refunds. Idempotency is critical for financial operations where duplicate processing causes monetary loss.
Example 50: Saga Pattern with Event Handlers
Coordinating long-running business processes across multiple aggregates using events.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
sequenceDiagram
participant O as Order Aggregate
participant S as Saga
participant P as Payment Aggregate
participant I as Inventory Aggregate
participant Ship as Shipping Aggregate
O->>S: OrderPlaced event
S->>P: processPayment()
P->>S: PaymentCompleted event
S->>I: reserveItems()
I->>S: ItemsReserved event
S->>Ship: scheduleShipment()
Ship->>S: ShipmentScheduled event
// Saga Coordinator - orchestrates multi-step process
class OrderFulfillmentSaga {
constructor(
// => Initialize object with parameters
private readonly sagaRepo: SagaStateRepository,
// => Encapsulated state, not directly accessible
private readonly inventoryService: InventoryService,
// => Encapsulated state, not directly accessible
private readonly shippingService: ShippingService,
// => Encapsulated state, not directly accessible
) {}
onOrderPlaced(event: OrderPlacedEvent): void {
// => Saga step 1: Order placed
console.log(`Saga started for order ${event.orderId}`);
// => Outputs result
// Initialize saga state
const sagaState = new SagaState(event.orderId, "STARTED");
// => Store value in sagaState
this.sagaRepo.save(sagaState);
// => Delegates to internal method
// Step 1: Reserve inventory
try {
this.inventoryService.reserve(event.orderId, event.items);
// => Delegates to internal method
sagaState.markInventoryReserved();
// => Execute method
this.sagaRepo.save(sagaState);
// => Delegates to internal method
console.log(`Inventory reserved for ${event.orderId}`);
// => Outputs result
} catch (error) {
// => Validates business rule
// Compensation: Saga failed at inventory step
sagaState.markFailed();
// => Execute method
this.sagaRepo.save(sagaState);
// => Delegates to internal method
console.log(`Saga failed: ${error.message}`);
// => Outputs result
return;
// => Enforces invariant
}
}
onInventoryReserved(event: InventoryReservedEvent): void {
// => Saga step 2: Inventory reserved
const sagaState = this.sagaRepo.findByOrderId(event.orderId);
// => Store value in sagaState
if (!sagaState || sagaState.getStatus() !== "INVENTORY_RESERVED") {
// => Conditional check
console.log("Saga state invalid - skipping");
// => Outputs result
return;
// => Aggregate boundary enforced here
}
// Step 2: Create shipment
try {
this.shippingService.createShipment(event.orderId);
// => Delegates to internal method
sagaState.markShipmentCreated();
// => Execute method
this.sagaRepo.save(sagaState);
// => Delegates to internal method
console.log(`Shipment created for ${event.orderId}`);
// => Outputs result
} catch (error) {
// => DDD tactical pattern applied
// Compensation: Rollback inventory reservation
this.inventoryService.release(event.orderId);
// => Delegates to internal method
sagaState.markFailed();
// => Execute method
this.sagaRepo.save(sagaState);
// => Delegates to internal method
console.log(`Saga compensated: released inventory`);
// => Outputs result
}
}
onShipmentCreated(event: ShipmentCreatedEvent): void {
// => Saga step 3: Shipment created (final step)
const sagaState = this.sagaRepo.findByOrderId(event.orderId);
// => Store value in sagaState
if (!sagaState) {
return;
// => Entity state transition managed
}
sagaState.markCompleted();
// => Execute method
this.sagaRepo.save(sagaState);
// => Delegates to internal method
console.log(`Saga completed for ${event.orderId}`);
// => Outputs result
}
// => Communicates domain intent
}
// Supporting classes
class OrderPlacedEvent {
constructor(
// => Initialize object with parameters
public readonly orderId: string,
public readonly items: string[],
) {}
}
// => Validates business rule
class InventoryReservedEvent {
constructor(public readonly orderId: string) {}
// => Initialize object with parameters
}
// => Enforces invariant
class ShipmentCreatedEvent {
constructor(public readonly orderId: string) {}
// => Initialize object with parameters
}
class SagaState {
constructor(
// => Initialize object with parameters
private readonly orderId: string,
// => Encapsulated state, not directly accessible
private status: SagaStatus,
// => Encapsulated field (not publicly accessible)
) {}
markInventoryReserved(): void {
this.status = "INVENTORY_RESERVED";
// => Update status state
}
markShipmentCreated(): void {
this.status = "SHIPMENT_CREATED";
// => Update status state
}
markCompleted(): void {
this.status = "COMPLETED";
// => Update status state
}
markFailed(): void {
this.status = "FAILED";
// => Update status state
}
getStatus(): SagaStatus {
return this.status;
// => Return result to caller
}
getOrderId(): string {
return this.orderId;
// => Return result to caller
}
}
type SagaStatus = "STARTED" | "INVENTORY_RESERVED" | "SHIPMENT_CREATED" | "COMPLETED" | "FAILED";
// => Domain model consistency maintained
interface SagaStateRepository {
// => SagaStateRepository: contract definition
findByOrderId(orderId: string): SagaState | null;
save(state: SagaState): void;
}
// => Communicates domain intent
class InMemorySagaStateRepository implements SagaStateRepository {
private states: Map<string, SagaState> = new Map();
// => Encapsulated field (not publicly accessible)
findByOrderId(orderId: string): SagaState | null {
return this.states.get(orderId) || null;
// => Return result to caller
}
save(state: SagaState): void {
this.states.set(state.getOrderId(), state);
// => Delegates to internal method
}
}
// => Validates business rule
interface InventoryService {
// => InventoryService: contract definition
reserve(orderId: string, items: string[]): void;
release(orderId: string): void;
}
// => Enforces invariant
class MockInventoryService implements InventoryService {
reserve(orderId: string, items: string[]): void {
console.log(`Inventory reserved: ${items.join(", ")}`);
// => Outputs result
}
release(orderId: string): void {
console.log(`Inventory released for ${orderId}`);
// => Outputs result
}
}
interface ShippingService {
// => ShippingService: contract definition
createShipment(orderId: string): void;
}
class MockShippingService implements ShippingService {
createShipment(orderId: string): void {
console.log(`Shipment created for ${orderId}`);
// => Outputs result
}
}
// Usage - Saga coordinates multi-step process
const sagaRepo = new InMemorySagaStateRepository();
// => Store value in sagaRepo
const inventoryService = new MockInventoryService();
// => Store value in inventoryService
const shippingService = new MockShippingService();
// => Store value in shippingService
const saga = new OrderFulfillmentSaga(sagaRepo, inventoryService, shippingService);
// => Store value in saga
// Step 1: Order placed
const orderPlaced = new OrderPlacedEvent("O123", ["P1", "P2"]);
// => Store value in orderPlaced
saga.onOrderPlaced(orderPlaced);
// => Output: Saga started for order O123
// => Output: Inventory reserved: P1, P2
// => Output: Inventory reserved for O123
// Step 2: Inventory reserved
const inventoryReserved = new InventoryReservedEvent("O123");
// => Store value in inventoryReserved
saga.onInventoryReserved(inventoryReserved);
// => Output: Shipment created for O123
// => Output: Shipment created for O123
// Step 3: Shipment created
const shipmentCreated = new ShipmentCreatedEvent("O123");
// => Store value in shipmentCreated
saga.onShipmentCreated(shipmentCreated);
// => Output: Saga completed for O123Key Takeaway: Saga pattern coordinates long-running processes across multiple aggregates using event-driven choreography. Each step publishes events; saga coordinator reacts by executing next step or compensating on failure. Saga state tracks progress and enables recovery.
Why It Matters: Sagas enable distributed transactions without distributed locks. RideCompletion sagas coordinate multiple steps: mark trip complete → process payment → update driver earnings → send receipt → award ratings. If payment fails, saga compensates by unmarking trip complete and releasing inventory. This achieves consistency across microservices without 2-phase commit, enabling horizontal scaling while maintaining business process integrity.
Example 51: Event Sourcing with Event Handlers
Rebuilding aggregate state from domain events.
// Domain Events
class AccountCreatedEvent {
constructor(
// => Initialize object with parameters
public readonly accountId: string,
public readonly initialBalance: number,
public readonly occurredAt: Date,
) {}
}
class MoneyDepositedEvent {
constructor(
// => Initialize object with parameters
public readonly accountId: string,
public readonly amount: number,
public readonly occurredAt: Date,
) {}
// => Validates business rule
}
// => Enforces invariant
class MoneyWithdrawnEvent {
constructor(
// => Initialize object with parameters
public readonly accountId: string,
public readonly amount: number,
public readonly occurredAt: Date,
) {}
}
type AccountEvent = AccountCreatedEvent | MoneyDepositedEvent | MoneyWithdrawnEvent;
// => Aggregate boundary enforced here
// Event-Sourced Aggregate
class EventSourcedBankAccount {
private accountId: string = "";
// => Encapsulated field (not publicly accessible)
private balance: number = 0;
// => Encapsulated field (not publicly accessible)
private version: number = 0;
// => Encapsulated field (not publicly accessible)
// Apply events to rebuild state
applyEvent(event: AccountEvent): void {
if (event instanceof AccountCreatedEvent) {
this.accountId = event.accountId;
// => Update accountId state
this.balance = event.initialBalance;
// => Update balance state
} else if (event instanceof MoneyDepositedEvent) {
// => Domain event triggered or handled
this.balance += event.amount;
// => State change operation
// => Modifies state value
// => Balance updated
} else if (event instanceof MoneyWithdrawnEvent) {
// => Cross-context interaction point
this.balance -= event.amount;
// => State change operation
// => Modifies state value
// => Balance updated
}
this.version++;
// => State updated from event
}
// Rebuild from event history
static fromEvents(events: AccountEvent[]): EventSourcedBankAccount {
const account = new EventSourcedBankAccount();
// => Store value in account
events.forEach((event) => account.applyEvent(event));
// => forEach: process collection elements
return account;
// => Account state reconstructed from events
}
getBalance(): number {
return this.balance;
// => Return result to caller
}
getVersion(): number {
return this.version;
// => Return result to caller
}
getAccountId(): string {
return this.accountId;
// => Return result to caller
}
// => Communicates domain intent
}
// Event Store
interface EventStore {
// => EventStore: contract definition
getEvents(accountId: string): AccountEvent[];
appendEvent(event: AccountEvent): void;
}
class InMemoryEventStore implements EventStore {
private events: Map<string, AccountEvent[]> = new Map();
// => Encapsulated field (not publicly accessible)
getEvents(accountId: string): AccountEvent[] {
return this.events.get(accountId) || [];
// => Return result to caller
}
// => Validates business rule
appendEvent(event: AccountEvent): void {
const accountId = event.accountId;
// => Store value in accountId
if (!this.events.has(accountId)) {
// => Conditional check
this.events.set(accountId, []);
// => Delegates to internal method
}
// => Enforces invariant
this.events.get(accountId)!.push(event);
// => Delegates to internal method
console.log(`Event stored: ${event.constructor.name}`);
// => Outputs result
}
}
// Event Handler - Projection builder
class AccountBalanceProjectionHandler {
private projections: Map<string, number> = new Map();
// => Encapsulated field (not publicly accessible)
handle(event: AccountEvent): void {
// => Build read model from events
const accountId = event.accountId;
// => Store value in accountId
let balance = this.projections.get(accountId) || 0;
// => Store value in balance
if (event instanceof AccountCreatedEvent) {
balance = event.initialBalance;
// => Aggregate boundary enforced here
} else if (event instanceof MoneyDepositedEvent) {
// => Domain event triggered or handled
balance += event.amount;
// => State change operation
// => Modifies state value
// => Balance updated
} else if (event instanceof MoneyWithdrawnEvent) {
// => Cross-context interaction point
balance -= event.amount;
// => State change operation
// => Modifies state value
// => Balance updated
}
this.projections.set(accountId, balance);
// => Delegates to internal method
console.log(`Projection updated: ${accountId} -> $${balance}`);
// => Outputs result
}
getBalance(accountId: string): number {
return this.projections.get(accountId) || 0;
// => Return result to caller
}
}
// Usage - Event Sourcing pattern
const eventStore = new InMemoryEventStore();
// => Store value in eventStore
const projectionHandler = new AccountBalanceProjectionHandler();
// => Store value in projectionHandler
// Create account
const created = new AccountCreatedEvent("ACC-001", 1000, new Date());
// => Store value in created
eventStore.appendEvent(created);
// => Execute method
projectionHandler.handle(created);
// => Output: Event stored: AccountCreatedEvent
// => Output: Projection updated: ACC-001 -> $1000
// Deposit money
const deposited = new MoneyDepositedEvent("ACC-001", 500, new Date());
// => Store value in deposited
eventStore.appendEvent(deposited);
// => Execute method
projectionHandler.handle(deposited);
// => Output: Event stored: MoneyDepositedEvent
// => Output: Projection updated: ACC-001 -> $1500
// Withdraw money
const withdrawn = new MoneyWithdrawnEvent("ACC-001", 300, new Date());
// => Store value in withdrawn
eventStore.appendEvent(withdrawn);
// => Execute method
projectionHandler.handle(withdrawn);
// => Output: Event stored: MoneyWithdrawnEvent
// => Output: Projection updated: ACC-001 -> $1200
// Rebuild aggregate from events
const events = eventStore.getEvents("ACC-001");
// => Store value in events
const account = EventSourcedBankAccount.fromEvents(events);
// => Store value in account
console.log(`Rebuilt account balance: $${account.getBalance()}, version: ${account.getVersion()}`);
// => Outputs result
// => Output: Rebuilt account balance: $1200, version: 3Key Takeaway: Event Sourcing stores domain events as source of truth, rebuilding aggregate state by replaying events. Event Handlers build projections (read models) from event streams, enabling multiple views of same data. Every state change is captured as event, providing complete audit trail.
Why It Matters: Event Sourcing enables time travel and audit compliance. Banks use Event Sourcing for account ledgers—every deposit, withdrawal, and fee is an immutable event. Regulators can audit exact account state at any point in history by replaying events to that timestamp. GitLab uses Event Sourcing for project timelines, enabling "rewind" to any project state. Trade-off: complexity (managing event schemas, rebuilding projections) vs. benefits (audit trail, temporal queries, event-driven architecture).
Example 52: Event Handler Error Handling and Dead Letter Queue
Handling failures in event processing gracefully.
// Domain Event
class OrderShippedEvent {
constructor(
// => Initialize object with parameters
public readonly eventId: string,
public readonly orderId: string,
public readonly trackingNumber: string,
public readonly occurredAt: Date,
) {}
}
// Dead Letter Queue - stores failed events for retry
interface DeadLetterQueue {
// => DeadLetterQueue: contract definition
addFailedEvent(event: any, error: Error, attemptCount: number): void;
getFailedEvents(): Array<{ event: any; error: Error; attemptCount: number }>;
}
class InMemoryDeadLetterQueue implements DeadLetterQueue {
private failedEvents: Array<{ event: any; error: Error; attemptCount: number }> = [];
// => Encapsulated field (not publicly accessible)
addFailedEvent(event: any, error: Error, attemptCount: number): void {
this.failedEvents.push({ event, error, attemptCount });
// => Delegates to internal method
console.log(`Event ${event.eventId} added to DLQ after ${attemptCount} attempts`);
// => Outputs result
}
getFailedEvents(): Array<{ event: any; error: Error; attemptCount: number }> {
return this.failedEvents;
// => Return result to caller
}
}
// Event Handler with retry logic
class SendTrackingEmailHandler {
private readonly MAX_RETRY_ATTEMPTS = 3;
// => Encapsulated state, not directly accessible
constructor(
// => Initialize object with parameters
private readonly emailService: EmailService,
// => Encapsulated state, not directly accessible
private readonly deadLetterQueue: DeadLetterQueue,
// => Encapsulated state, not directly accessible
) {}
handle(event: OrderShippedEvent, attemptCount: number = 1): void {
// => Handle event with retry logic
console.log(`Processing OrderShippedEvent ${event.eventId}, attempt ${attemptCount}`);
// => Outputs result
try {
// Attempt to send email
this.emailService.sendTrackingEmail(event.orderId, event.trackingNumber);
// => Delegates to internal method
console.log(`Tracking email sent for ${event.orderId}`);
// => Outputs result
// => Success
} catch (error) {
// => Failure occurred
console.log(`Error sending email: ${error.message}`);
// => Outputs result
if (attemptCount < this.MAX_RETRY_ATTEMPTS) {
// Retry
console.log(`Retrying... (${attemptCount + 1}/${this.MAX_RETRY_ATTEMPTS})`);
// => Delegates to internal method
// => Outputs result
this.handle(event, attemptCount + 1);
// => Delegates to internal method
// => Recursive retry
} else {
// Max retries exceeded - move to DLQ
this.deadLetterQueue.addFailedEvent(event, error, attemptCount);
// => Delegates to internal method
console.log(`Max retries exceeded - event moved to DLQ`);
// => Outputs result
// => Failed permanently, requires manual intervention
}
}
}
}
interface EmailService {
// => EmailService: contract definition
sendTrackingEmail(orderId: string, trackingNumber: string): void;
}
class UnreliableEmailService implements EmailService {
private attemptCount: number = 0;
// => Encapsulated field (not publicly accessible)
private readonly failUntilAttempt: number;
// => Encapsulated state, not directly accessible
constructor(failUntilAttempt: number) {
// => Initialize object with parameters
this.failUntilAttempt = failUntilAttempt;
// => Update failUntilAttempt state
}
sendTrackingEmail(orderId: string, trackingNumber: string): void {
this.attemptCount++;
if (this.attemptCount < this.failUntilAttempt) {
throw new Error("Email service temporarily unavailable");
// => Raise domain exception
}
// Success on Nth attempt
console.log(`Email service: Tracking email sent for ${orderId}`);
// => Outputs result
}
reset(): void {
this.attemptCount = 0;
// => Update attemptCount state
}
}
// Usage - Event handler with retry and DLQ
const dlq = new InMemoryDeadLetterQueue();
// => Store value in dlq
// Scenario 1: Success after retry
console.log("=== Scenario 1: Success after retry ===");
// => Outputs result
const emailService1 = new UnreliableEmailService(2); // Fails once, succeeds on attempt 2
// => Store value in emailService1
const handler1 = new SendTrackingEmailHandler(emailService1, dlq);
// => Store value in handler1
const event1 = new OrderShippedEvent("EVT-001", "O123", "TRK-456", new Date());
// => Store value in event1
handler1.handle(event1);
// => Output: Processing OrderShippedEvent EVT-001, attempt 1
// => Output: Error sending email: Email service temporarily unavailable
// => Output: Retrying... (2/3)
// => Output: Processing OrderShippedEvent EVT-001, attempt 2
// => Output: Email service: Tracking email sent for O123
// => Output: Tracking email sent for O123
// Scenario 2: Max retries exceeded - DLQ
console.log("\n=== Scenario 2: Max retries exceeded ===");
// => Outputs result
const emailService2 = new UnreliableEmailService(10); // Always fails
// => Store value in emailService2
const handler2 = new SendTrackingEmailHandler(emailService2, dlq);
// => Store value in handler2
const event2 = new OrderShippedEvent("EVT-002", "O456", "TRK-789", new Date());
// => Store value in event2
handler2.handle(event2);
// => Output: Processing OrderShippedEvent EVT-002, attempt 1
// => Output: Error sending email: Email service temporarily unavailable
// => Output: Retrying... (2/3)
// => Output: Processing OrderShippedEvent EVT-002, attempt 2
// => Output: Error sending email: Email service temporarily unavailable
// => Output: Retrying... (3/3)
// => Output: Processing OrderShippedEvent EVT-002, attempt 3
// => Output: Error sending email: Email service temporarily unavailable
// => Output: Event EVT-002 added to DLQ after 3 attempts
// => Output: Max retries exceeded - event moved to DLQ
console.log(`\nDead Letter Queue size: ${dlq.getFailedEvents().length}`);
// => Outputs result
// => Output: Dead Letter Queue size: 1Key Takeaway: Event Handlers implement retry logic for transient failures and Dead Letter Queue for permanent failures. Retry with exponential backoff handles temporary issues (network glitch, service restart); DLQ captures events that fail repeatedly, requiring manual investigation or reprocessing.
Why It Matters: Distributed systems face transient failures frequently. Event handlers retry failed events multiple times before moving to DLQ. Event handlers use exponential backoff (increasing delays) to avoid overwhelming recovering services. DLQ enables operations teams to investigate failures (bad data, service bugs) and reprocess events after fixes. Without retry + DLQ, events are lost permanently, causing data inconsistencies.
Advanced Factories (Examples 53-55)
Example 53: Factory with Complex Validation
Factories encapsulating complex validation and construction logic for aggregates.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Factory\ncreate(params)"]
B["Validate inputs"]
C["Construct Aggregate\nall invariants satisfied"]
D["Return valid Aggregate"]
E["Throw error\nif invalid"]
A --> B
B -->|valid| C
B -->|invalid| E
C --> D
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#CC78BC,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#029E73,stroke:#000,color:#fff
style E fill:#CA9161,stroke:#000,color:#fff
// Value Objects
class EmailAddress {
private constructor(private readonly value: string) {}
// => Initialize object with parameters
static create(email: string): EmailAddress {
// => Value object factory with validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// => Store value in emailRegex
if (!emailRegex.test(email)) {
// => Conditional check
throw new Error("Invalid email format");
// => Raise domain exception
}
return new EmailAddress(email);
// => Return result to caller
}
getValue(): string {
return this.value;
// => Return result to caller
}
// => Validates business rule
}
// => Enforces invariant
class PhoneNumber {
private constructor(private readonly value: string) {}
// => Initialize object with parameters
static create(phone: string): PhoneNumber {
// => Value object factory with validation
const phoneRegex = /^\+?[1-9]\d{1,14}$/; // E.164 format
// => Store value in phoneRegex
if (!phoneRegex.test(phone)) {
// => Conditional check
throw new Error("Invalid phone format (use E.164)");
// => Raise domain exception
}
return new PhoneNumber(phone);
// => Return result to caller
}
getValue(): string {
return this.value;
// => Return result to caller
}
}
// Aggregate
class Customer {
private constructor(
// => Initialize object with parameters
private readonly customerId: string,
// => Encapsulated state, not directly accessible
private readonly name: string,
// => Encapsulated state, not directly accessible
private readonly email: EmailAddress,
// => Encapsulated state, not directly accessible
private readonly phone: PhoneNumber,
// => Encapsulated state, not directly accessible
private readonly creditLimit: number,
// => Encapsulated state, not directly accessible
private readonly accountStatus: AccountStatus,
// => Encapsulated state, not directly accessible
) {}
// Factory method with complex validation
static create(name: string, email: string, phone: string, requestedCreditLimit: number): Customer {
// => Factory encapsulates complex creation logic
// Validation 1: Name requirements
if (!name || name.trim().length < 2) {
// => Conditional check
throw new Error("Name must be at least 2 characters");
// => Raise domain exception
}
if (name.length > 100) {
throw new Error("Name cannot exceed 100 characters");
// => Raise domain exception
}
// Validation 2: Email (delegated to value object)
const emailVO = EmailAddress.create(email);
// => Throws if invalid
// Validation 3: Phone (delegated to value object)
const phoneVO = PhoneNumber.create(phone);
// => Throws if invalid
// Business Rule: Credit limit determination
let creditLimit: number;
// => Transaction boundary maintained
if (requestedCreditLimit <= 0) {
throw new Error("Credit limit must be positive");
// => Raise domain exception
} else if (requestedCreditLimit <= 5000) {
// => Entity state transition managed
creditLimit = requestedCreditLimit; // => Auto-approve small limits
} else if (requestedCreditLimit <= 50000) {
// => Communicates domain intent
creditLimit = 5000; // => Cap at 5000 for manual review
} else {
throw new Error("Credit limit exceeds maximum (50000)");
// => Raise domain exception
}
// Business Rule: Initial account status
const accountStatus: AccountStatus = creditLimit > 1000 ? "ACTIVE" : "PENDING_APPROVAL";
// => Accounts with >$1000 credit auto-approved
const customerId = `CUST-${Date.now()}`;
// => Store value in customerId
return new Customer(customerId, name, emailVO, phoneVO, creditLimit, accountStatus);
// => Return result to caller
}
// => Communicates domain intent
getCustomerId(): string {
return this.customerId;
// => Return result to caller
}
getCreditLimit(): number {
return this.creditLimit;
// => Return result to caller
}
getAccountStatus(): AccountStatus {
return this.accountStatus;
// => Return result to caller
}
// => Validates business rule
getEmail(): string {
return this.email.getValue();
// => Return result to caller
}
// => Enforces invariant
}
type AccountStatus = "ACTIVE" | "PENDING_APPROVAL" | "SUSPENDED";
// => Execution delegated to domain service
// Usage - Factory with complex validation
try {
const customer = Customer.create("Alice Johnson", "alice@example.com", "+14155552671", 10000);
// => Store value in customer
console.log(`Customer created: ${customer.getCustomerId()}`);
// => Outputs result
console.log(`Credit limit: $${customer.getCreditLimit()}`);
// => Outputs result
console.log(`Status: ${customer.getAccountStatus()}`);
// => Outputs result
// => Output: Customer created: CUST-[timestamp]
// => Output: Credit limit: $5000 (capped for manual review)
// => Output: Status: ACTIVE
} catch (error) {
// => Domain event triggered or handled
console.log(`Creation failed: ${error.message}`);
// => Outputs result
}
// Invalid inputs caught by factory
try {
Customer.create("A", "invalid-email", "123", -1000);
// => Execute method
} catch (error) {
// => Invariant validation executed
console.log(`Validation error: ${error.message}`);
// => Outputs result
// => Output: Validation error: Name must be at least 2 characters
}Key Takeaway: Factories encapsulate complex validation and business rules for aggregate creation, ensuring only valid aggregates enter the system. Validation logic centralized in factory method prevents duplicating rules across application layer.
Why It Matters: Factories enforce invariants at creation time. A payment platform's Customer factory validates email, payment method, and compliance requirements before creating Customer aggregate—preventing invalid customers in system. This "fail fast" approach catches errors immediately rather than discovering invalid state later. Factories reduce Application Service complexity by handling validation internally, making services thin orchestration layers.
Example 54: Factory for Reconstituting Aggregates from Persistence
Separating creation logic (business rules) from reconstitution logic (loading from database).
// Aggregate
class Order {
private constructor(
// => Initialize object with parameters
private readonly orderId: string,
// => Encapsulated state, not directly accessible
private readonly customerId: string,
// => Encapsulated state, not directly accessible
private readonly items: OrderItem[],
// => Encapsulated state, not directly accessible
private status: OrderStatus,
// => Encapsulated field (not publicly accessible)
private readonly createdAt: Date,
// => Encapsulated state, not directly accessible
) {}
// Factory for NEW orders (enforces business rules)
static create(customerId: string, items: OrderItem[]): Order {
// => Creation factory with validation
if (!customerId || customerId.trim().length === 0) {
// => Conditional check
throw new Error("Customer ID required");
// => Raise domain exception
}
if (items.length === 0) {
throw new Error("Order must have at least one item");
// => Raise domain exception
}
// => Validates business rule
const orderId = `ORD-${Date.now()}`;
// => Store value in orderId
const status: OrderStatus = "PENDING";
// => Enforces invariant
const createdAt = new Date();
// => Store value in createdAt
return new Order(orderId, customerId, items, status, createdAt);
// => New order created with initial state
}
// Factory for EXISTING orders (reconstitution from database)
static reconstitute(
// => Execution delegated to domain service
orderId: string,
// => Aggregate boundary enforced here
customerId: string,
// => Domain event triggered or handled
items: OrderItem[],
// => Cross-context interaction point
status: OrderStatus,
// => DDD tactical pattern applied
createdAt: Date,
// => Invariant validation executed
): Order {
// => Reconstitution factory - NO validation
// Assumes data from database is already valid
return new Order(orderId, customerId, items, status, createdAt);
// => Order rebuilt from persisted state
}
confirm(): void {
if (this.status !== "PENDING") {
throw new Error("Order already processed");
// => Raise domain exception
}
this.status = "CONFIRMED";
// => Update status state
}
getOrderId(): string {
return this.orderId;
// => Return result to caller
}
// => Communicates domain intent
getStatus(): OrderStatus {
return this.status;
// => Return result to caller
}
getCreatedAt(): Date {
return this.createdAt;
// => Return result to caller
}
}
// => Validates business rule
class OrderItem {
constructor(
// => Initialize object with parameters
public readonly productId: string,
public readonly quantity: number,
public readonly price: number,
) {}
// => Enforces invariant
}
type OrderStatus = "PENDING" | "CONFIRMED" | "SHIPPED";
// => Execution delegated to domain service
// Repository uses reconstitute factory
interface OrderRepository {
// => OrderRepository: contract definition
save(order: Order): void;
findById(orderId: string): Order | null;
}
class InMemoryOrderRepository implements OrderRepository {
private orders: Map<
// => Encapsulated field (not publicly accessible)
string,
// => Domain event triggered or handled
{ id: string; customerId: string; items: OrderItem[]; status: OrderStatus; createdAt: Date }
// => Cross-context interaction point
> = new Map();
// => Create Map instance
save(order: Order): void {
// Persist order (simplified)
this.orders.set(order.getOrderId(), {
// => Delegates to internal method
id: order.getOrderId(),
// => Execute method
customerId: "C123", // Simplified
// => DDD tactical pattern applied
items: [],
// => Invariant validation executed
status: order.getStatus(),
// => Execute method
createdAt: order.getCreatedAt(),
// => Execute method
});
}
findById(orderId: string): Order | null {
const data = this.orders.get(orderId);
// => Store value in data
if (!data) {
return null;
}
// Use reconstitute factory to rebuild aggregate
return Order.reconstitute(data.id, data.customerId, data.items, data.status, data.createdAt);
// => Order rebuilt from persisted data
}
// => Communicates domain intent
}
// Usage - Different factories for creation vs reconstitution
const repository = new InMemoryOrderRepository();
// => Store value in repository
// Scenario 1: Create new order (validation enforced)
const items = [new OrderItem("P1", 2, 5000)];
// => Store value in items
const newOrder = Order.create("C123", items);
// => Store value in newOrder
console.log(`New order created: ${newOrder.getOrderId()}, status: ${newOrder.getStatus()}`);
// => Outputs result
// => Output: New order created: ORD-[timestamp], status: PENDING
newOrder.confirm();
// => Execute method
repository.save(newOrder);
// => Execute method
// Scenario 2: Load existing order (no validation)
const loadedOrder = repository.findById(newOrder.getOrderId());
// => Store value in loadedOrder
if (loadedOrder) {
console.log(`Loaded order: ${loadedOrder.getOrderId()}, status: ${loadedOrder.getStatus()}`);
// => Outputs result
// => Output: Loaded order: ORD-[timestamp], status: CONFIRMED
}Key Takeaway: Separate factory methods for creation (validate business rules) vs reconstitution (load from database). Creation factories enforce invariants; reconstitution factories trust persisted data. This prevents unnecessary validation on every database load while ensuring new aggregates are valid.
Why It Matters: Performance and correctness trade-off. Re-validating aggregates on every database load wastes CPU cycles—data in database already validated during creation. However, creation must validate to prevent invalid state from entering system. Domain-Driven Design in Practice (Vladimir Khorikov) recommends separate factories for these concerns, improving performance while maintaining data integrity.
Example 55: Abstract Factory for Polymorphic Aggregate Creation
Using Abstract Factory pattern to create different aggregate types based on business rules.
// Abstract base
abstract class DiscountPolicy {
abstract calculate(orderAmount: number): number;
// => Domain operation executes here
abstract getType(): string;
// => Modifies aggregate internal state
}
// => Validates business rule
// Concrete implementations
class PercentageDiscountPolicy extends DiscountPolicy {
constructor(private readonly percentage: number) {
// => Initialize object with parameters
super();
// => Enforces invariant
}
calculate(orderAmount: number): number {
return Math.floor(orderAmount * (this.percentage / 100));
// => Calculate percentage discount
}
getType(): string {
return `${this.percentage}% Discount`;
}
}
class FixedAmountDiscountPolicy extends DiscountPolicy {
constructor(private readonly fixedAmount: number) {
// => Initialize object with parameters
super();
// => Cross-context interaction point
}
calculate(orderAmount: number): number {
return Math.min(this.fixedAmount, orderAmount);
// => Fixed amount, capped at order amount
}
getType(): string {
return `$${this.fixedAmount / 100} Discount`;
}
}
class NoDiscountPolicy extends DiscountPolicy {
calculate(orderAmount: number): number {
return 0; // => No discount
}
getType(): string {
return "No Discount";
}
// => Communicates domain intent
}
// Abstract Factory
class DiscountPolicyFactory {
static create(customerType: string, orderAmount: number, loyaltyPoints: number): DiscountPolicy {
// => Factory logic based on business rules
if (customerType === "VIP") {
// VIP customers get 20% discount
return new PercentageDiscountPolicy(20);
// => Return result to caller
} else if (customerType === "REGULAR" && loyaltyPoints > 1000) {
// => Create data structure
// Loyal regular customers get 10% discount
return new PercentageDiscountPolicy(10);
// => Return result to caller
} else if (orderAmount > 50000) {
// => Modifies aggregate internal state
// Large orders get $50 discount
return new FixedAmountDiscountPolicy(5000); // $50
// => Return result to caller
} else {
// => Validates business rule
// Default: no discount
return new NoDiscountPolicy();
// => Return result to caller
}
// => Enforces invariant
}
}
// Usage - Abstract Factory creates polymorphic objects
const vipPolicy = DiscountPolicyFactory.create("VIP", 10000, 500);
// => Store value in vipPolicy
console.log(`${vipPolicy.getType()}: $${vipPolicy.calculate(10000) / 100}`);
// => Outputs result
// => Output: 20% Discount: $20
const regularLoyalPolicy = DiscountPolicyFactory.create("REGULAR", 10000, 1500);
// => Store value in regularLoyalPolicy
console.log(`${regularLoyalPolicy.getType()}: $${regularLoyalPolicy.calculate(10000) / 100}`);
// => Outputs result
// => Output: 10% Discount: $10
const largeOrderPolicy = DiscountPolicyFactory.create("REGULAR", 60000, 0);
// => Store value in largeOrderPolicy
console.log(`${largeOrderPolicy.getType()}: $${largeOrderPolicy.calculate(60000) / 100}`);
// => Outputs result
// => Output: $50 Discount: $50
const noDiscountPolicy = DiscountPolicyFactory.create("REGULAR", 5000, 0);
// => Store value in noDiscountPolicy
console.log(`${noDiscountPolicy.getType()}: $${noDiscountPolicy.calculate(5000) / 100}`);
// => Outputs result
// => Output: No Discount: $0Key Takeaway: Abstract Factory creates polymorphic aggregates based on business rules, encapsulating complex selection logic. Client code receives interface/base class, unaware of concrete implementation. This enables Strategy pattern with centralized creation logic.
Why It Matters: Abstract Factories prevent conditional logic sprawl. A ride-sharing platform's PricingStrategyFactory creates different pricing algorithms (surge pricing, flat rate, time-based) based on city, time, and demand. Without factory, every pricing call would need complex if-else chains to select algorithm. Factory centralizes selection logic, and client code (trip calculation) works with PricingStrategy interface regardless of concrete implementation. This enables A/B testing new pricing algorithms by modifying factory logic, not client code.
Specifications Pattern (Examples 56-58)
Example 56: Basic Specification for Business Rules
Encapsulating business rules as reusable, composable objects.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph LR
A["SpecificationA\nisSatisfiedBy()"]
B["SpecificationB\nisSatisfiedBy()"]
C["A.and(B)\nComposite Spec"]
D["A.or(B)\nComposite Spec"]
E["A.not()\nNegated Spec"]
A -->|combines| C
B -->|combines| C
A -->|combines| D
B -->|combines| D
A -->|negates| E
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#000
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#029E73,stroke:#000,color:#fff
style E fill:#CC78BC,stroke:#000,color:#fff
// Specification interface
interface Specification<T> {
// => Specification: contract definition
isSatisfiedBy(candidate: T): boolean;
}
// Domain entity
class Product {
constructor(
// => Initialize object with parameters
private readonly productId: string,
// => Encapsulated state, not directly accessible
private readonly name: string,
// => Encapsulated state, not directly accessible
private readonly price: number,
// => Encapsulated state, not directly accessible
private readonly category: string,
// => Encapsulated state, not directly accessible
private readonly inStock: boolean,
// => Encapsulated state, not directly accessible
) {}
getPrice(): number {
return this.price;
// => Return result to caller
}
// => Validates business rule
getCategory(): string {
return this.category;
// => Return result to caller
}
// => Enforces invariant
isInStock(): boolean {
return this.inStock;
// => Return result to caller
}
getName(): string {
return this.name;
// => Return result to caller
}
}
// Concrete specifications
class PriceRangeSpecification implements Specification<Product> {
constructor(
// => Initialize object with parameters
private readonly minPrice: number,
// => Encapsulated state, not directly accessible
private readonly maxPrice: number,
// => Encapsulated state, not directly accessible
) {}
isSatisfiedBy(product: Product): boolean {
const price = product.getPrice();
// => Store value in price
return price >= this.minPrice && price <= this.maxPrice;
// => Check if product price in range
}
}
class CategorySpecification implements Specification<Product> {
constructor(private readonly category: string) {}
// => Initialize object with parameters
isSatisfiedBy(product: Product): boolean {
return product.getCategory() === this.category;
// => Check if product matches category
}
}
class InStockSpecification implements Specification<Product> {
isSatisfiedBy(product: Product): boolean {
return product.isInStock();
// => Check if product in stock
}
}
// Composite specifications (AND, OR, NOT)
class AndSpecification<T> implements Specification<T> {
constructor(
// => Initialize object with parameters
private readonly left: Specification<T>,
// => Encapsulated state, not directly accessible
private readonly right: Specification<T>,
// => Encapsulated state, not directly accessible
) {}
// => Communicates domain intent
isSatisfiedBy(candidate: T): boolean {
return this.left.isSatisfiedBy(candidate) && this.right.isSatisfiedBy(candidate);
// => Both specifications must be satisfied
}
}
class OrSpecification<T> implements Specification<T> {
constructor(
// => Initialize object with parameters
private readonly left: Specification<T>,
// => Encapsulated state, not directly accessible
private readonly right: Specification<T>,
// => Encapsulated state, not directly accessible
) {}
// => Validates business rule
isSatisfiedBy(candidate: T): boolean {
return this.left.isSatisfiedBy(candidate) || this.right.isSatisfiedBy(candidate);
// => Either specification satisfied
}
// => Enforces invariant
}
class NotSpecification<T> implements Specification<T> {
constructor(private readonly spec: Specification<T>) {}
// => Initialize object with parameters
isSatisfiedBy(candidate: T): boolean {
return !this.spec.isSatisfiedBy(candidate);
// => Negates specification
}
}
// Usage - Composable business rules
const products = [
// => Store value in products
new Product("P1", "Laptop", 120000, "Electronics", true),
// => Domain event triggered or handled
new Product("P2", "Book", 2000, "Books", true),
// => Cross-context interaction point
new Product("P3", "Phone", 80000, "Electronics", false),
// => DDD tactical pattern applied
new Product("P4", "Desk", 50000, "Furniture", true),
// => Invariant validation executed
];
// => Transaction boundary maintained
// Specification: Electronics in stock, price $500-$1500
const electronicsSpec = new CategorySpecification("Electronics");
// => Store value in electronicsSpec
const inStockSpec = new InStockSpecification();
// => Store value in inStockSpec
const priceSpec = new PriceRangeSpecification(50000, 150000);
// => Store value in priceSpec
const combinedSpec = new AndSpecification(electronicsSpec, new AndSpecification(inStockSpec, priceSpec));
// => Complex specification composed from simple ones
const matchingProducts = products.filter((p) => combinedSpec.isSatisfiedBy(p));
// => Store value in matchingProducts
matchingProducts.forEach((p) => {
// => forEach: process collection elements
console.log(`Match: ${p.getName()} - $${p.getPrice() / 100}`);
// => Outputs result
});
// => Output: Match: Laptop - $1200Key Takeaway: Specification pattern encapsulates business rules as objects that can be combined using AND, OR, NOT operators. This enables reusable, testable, composable business logic separate from domain entities and repositories.
Why It Matters: Specifications prevent business rule duplication. E-commerce search filters (price range, category, in-stock) become reusable Specification objects rather than SQL WHERE clauses scattered across repositories. Major e-commerce platforms use Specifications for product eligibility rules (can ship to certain countries, eligible for promotions, available for gift wrapping)—same rules apply in search, checkout, and recommendations without duplicating logic. Specifications are unit-testable in isolation, improving code quality.
Example 57: Specification for Repository Queries
Using Specifications to encapsulate complex query logic in repositories.
// Specification interface
interface Specification<T> {
// => Specification: contract definition
isSatisfiedBy(candidate: T): boolean;
toSQLWhereClause?(): string; // Optional: for database queries
// => Domain operation executes here
}
// Customer entity
class Customer {
constructor(
// => Initialize object with parameters
private readonly customerId: string,
// => Encapsulated state, not directly accessible
private readonly name: string,
// => Encapsulated state, not directly accessible
private readonly totalSpent: number,
// => Encapsulated state, not directly accessible
private readonly loyaltyTier: string,
// => Encapsulated state, not directly accessible
) {}
// => Validates business rule
getTotalSpent(): number {
return this.totalSpent;
// => Return result to caller
}
// => Enforces invariant
getLoyaltyTier(): string {
return this.loyaltyTier;
// => Return result to caller
}
getCustomerId(): string {
return this.customerId;
// => Return result to caller
}
getName(): string {
return this.name;
// => Return result to caller
}
}
// Specifications
class HighValueCustomerSpecification implements Specification<Customer> {
// => Immutable value type (no identity)
private readonly HIGH_VALUE_THRESHOLD = 100000; // $1000
// => Encapsulated state, not directly accessible
isSatisfiedBy(customer: Customer): boolean {
return customer.getTotalSpent() >= this.HIGH_VALUE_THRESHOLD;
}
toSQLWhereClause(): string {
return `total_spent >= ${this.HIGH_VALUE_THRESHOLD}`;
// => Converts to SQL for repository query
}
}
class PremiumTierSpecification implements Specification<Customer> {
isSatisfiedBy(customer: Customer): boolean {
return customer.getLoyaltyTier() === "PREMIUM";
}
toSQLWhereClause(): string {
return `loyalty_tier = 'PREMIUM'`;
}
}
// Composite specification with SQL generation
class AndSpecificationWithSQL<T> implements Specification<T> {
constructor(
// => Initialize object with parameters
private readonly left: Specification<T>,
// => Encapsulated state, not directly accessible
private readonly right: Specification<T>,
// => Encapsulated state, not directly accessible
) {}
// => Communicates domain intent
isSatisfiedBy(candidate: T): boolean {
return this.left.isSatisfiedBy(candidate) && this.right.isSatisfiedBy(candidate);
// => Return result to caller
}
toSQLWhereClause(): string {
const leftSQL = this.left.toSQLWhereClause?.() || "";
// => Store value in leftSQL
const rightSQL = this.right.toSQLWhereClause?.() || "";
// => Store value in rightSQL
return `(${leftSQL} AND ${rightSQL})`;
// => Combines SQL clauses with AND
}
}
// => Validates business rule
// Repository using specifications
class CustomerRepository {
private customers: Customer[] = [];
// => Encapsulated field (not publicly accessible)
constructor() {
// => Initialize object with parameters
// Seed data
this.customers = [
// => Update customers state
new Customer("C1", "Alice", 150000, "PREMIUM"),
// => Enforces invariant
new Customer("C2", "Bob", 50000, "REGULAR"),
// => Business rule enforced here
new Customer("C3", "Carol", 200000, "PREMIUM"),
// => Execution delegated to domain service
new Customer("C4", "Dave", 30000, "REGULAR"),
// => Aggregate boundary enforced here
];
// => Domain event triggered or handled
}
findBySpecification(spec: Specification<Customer>): Customer[] {
// In-memory filtering (use spec.toSQLWhereClause() for real database)
return this.customers.filter((c) => spec.isSatisfiedBy(c));
// => Repository delegates filtering to specification
}
// Simulate SQL query generation
generateSQLQuery(spec: Specification<Customer>): string {
const whereClause = spec.toSQLWhereClause?.() || "1=1";
// => Store value in whereClause
return `SELECT * FROM customers WHERE ${whereClause}`;
// => Specification generates SQL WHERE clause
}
}
// Usage - Repository queries with specifications
const repository = new CustomerRepository();
// => Store value in repository
// Find high-value premium customers
const highValueSpec = new HighValueCustomerSpecification();
// => Store value in highValueSpec
const premiumSpec = new PremiumTierSpecification();
// => Store value in premiumSpec
const combinedSpec = new AndSpecificationWithSQL(highValueSpec, premiumSpec);
// => Store value in combinedSpec
const customers = repository.findBySpecification(combinedSpec);
// => Store value in customers
customers.forEach((c) => {
// => forEach: process collection elements
console.log(`Customer: ${c.getName()}, Spent: $${c.getTotalSpent() / 100}, Tier: ${c.getLoyaltyTier()}`);
// => Outputs result
});
// => Output: Customer: Alice, Spent: $1500, Tier: PREMIUM
// => Output: Customer: Carol, Spent: $2000, Tier: PREMIUM
// Generate SQL query
const sqlQuery = repository.generateSQLQuery(combinedSpec);
// => Store value in sqlQuery
console.log(`Generated SQL: ${sqlQuery}`);
// => Outputs result
// => Output: Generated SQL: SELECT * FROM customers WHERE (total_spent >= 100000 AND loyalty_tier = 'PREMIUM')Key Takeaway: Specifications can encapsulate both in-memory filtering logic and database query generation. Repository methods accept Specification parameters, delegating query construction to business-rule objects. This keeps repositories thin and business rules explicit.
Why It Matters: Specifications with SQL generation enable query optimization while maintaining business rule centralization. Professional networking platforms use Specifications that generate optimized SQL—preventing N+1 queries while keeping business logic (e.g., "active connections over threshold") in domain layer, not SQL strings. This separation enables testing business rules without databases and migrating between SQL/NoSQL without rewriting business logic.
Example 58: Specification for Validation
Using Specifications to validate complex business rules during aggregate state changes.
// Specification for validation
interface ValidationSpecification<T> {
// => ValidationSpecification: contract definition
isSatisfiedBy(candidate: T): boolean;
getErrorMessage(): string;
}
// Loan application entity
class LoanApplication {
private constructor(
// => Initialize object with parameters
private readonly applicationId: string,
// => Encapsulated state, not directly accessible
private readonly applicantIncome: number,
// => Encapsulated state, not directly accessible
private readonly requestedAmount: number,
// => Encapsulated state, not directly accessible
private readonly creditScore: number,
// => Encapsulated state, not directly accessible
private status: LoanStatus = "PENDING",
// => Encapsulated field (not publicly accessible)
) {}
static create(applicantIncome: number, requestedAmount: number, creditScore: number): LoanApplication {
const applicationId = `LOAN-${Date.now()}`;
// => Store value in applicationId
return new LoanApplication(applicationId, applicantIncome, requestedAmount, creditScore);
// => Return result to caller
}
// => Validates business rule
approve(validationSpec: ValidationSpecification<LoanApplication>): void {
// => Validate using specification before approval
if (!validationSpec.isSatisfiedBy(this)) {
// => Conditional check
throw new Error(`Cannot approve: ${validationSpec.getErrorMessage()}`);
// => Raise domain exception
}
// => Enforces invariant
this.status = "APPROVED";
// => Loan approved
}
getApplicantIncome(): number {
return this.applicantIncome;
// => Return result to caller
}
getRequestedAmount(): number {
return this.requestedAmount;
// => Return result to caller
}
getCreditScore(): number {
return this.creditScore;
// => Return result to caller
}
getStatus(): LoanStatus {
return this.status;
// => Return result to caller
}
getApplicationId(): string {
return this.applicationId;
// => Return result to caller
}
}
type LoanStatus = "PENDING" | "APPROVED" | "REJECTED";
// => Transaction boundary maintained
// Validation specifications
class MinimumIncomeSpecification implements ValidationSpecification<LoanApplication> {
private readonly MINIMUM_INCOME = 30000; // $300/month
// => Encapsulated state, not directly accessible
isSatisfiedBy(application: LoanApplication): boolean {
return application.getApplicantIncome() >= this.MINIMUM_INCOME;
}
getErrorMessage(): string {
return `Applicant income must be at least $${this.MINIMUM_INCOME / 100}`;
}
}
// => Communicates domain intent
class DebtToIncomeRatioSpecification implements ValidationSpecification<LoanApplication> {
private readonly MAX_DEBT_TO_INCOME_RATIO = 0.43; // 43%
// => Encapsulated state, not directly accessible
isSatisfiedBy(application: LoanApplication): boolean {
const ratio = application.getRequestedAmount() / application.getApplicantIncome();
// => Store value in ratio
return ratio <= this.MAX_DEBT_TO_INCOME_RATIO;
// => Check debt-to-income ratio
}
getErrorMessage(): string {
return `Debt-to-income ratio exceeds maximum (${this.MAX_DEBT_TO_INCOME_RATIO * 100}%)`;
}
}
// => Validates business rule
class MinimumCreditScoreSpecification implements ValidationSpecification<LoanApplication> {
private readonly MINIMUM_CREDIT_SCORE = 650;
// => Encapsulated state, not directly accessible
isSatisfiedBy(application: LoanApplication): boolean {
return application.getCreditScore() >= this.MINIMUM_CREDIT_SCORE;
}
// => Enforces invariant
getErrorMessage(): string {
return `Credit score must be at least ${this.MINIMUM_CREDIT_SCORE}`;
}
}
// Composite validation specification
class LoanApprovalSpecification implements ValidationSpecification<LoanApplication> {
private readonly specs: ValidationSpecification<LoanApplication>[];
// => Encapsulated state, not directly accessible
constructor() {
// => Initialize object with parameters
this.specs = [
// => Update specs state
new MinimumIncomeSpecification(),
// => Aggregate boundary enforced here
new DebtToIncomeRatioSpecification(),
// => Domain event triggered or handled
new MinimumCreditScoreSpecification(),
// => Cross-context interaction point
];
// => DDD tactical pattern applied
}
isSatisfiedBy(application: LoanApplication): boolean {
return this.specs.every((spec) => spec.isSatisfiedBy(application));
// => All validation rules must pass
}
getErrorMessage(): string {
const failures = this.specs.filter((spec) => !spec.isSatisfiedBy).map((spec) => spec.getErrorMessage());
// => Store value in failures
return failures.join("; ");
// => Return all validation errors
}
}
// Usage - Specifications for validation
const approvalSpec = new LoanApprovalSpecification();
// => Store value in approvalSpec
// Scenario 1: Valid application
const validApplication = LoanApplication.create(
// => Store value in validApplication
100000, // $1000 income
// => Communicates domain intent
40000, // $400 requested (40% ratio)
// => Domain operation executes here
700, // Credit score 700
// => Modifies aggregate internal state
);
// => Validates business rule
try {
// => Enforces invariant
validApplication.approve(approvalSpec);
// => Execute method
console.log(`Loan ${validApplication.getApplicationId()} approved`);
// => Outputs result
// => Output: Loan LOAN-[timestamp] approved
} catch (error) {
// => Business rule enforced here
console.log(error.message);
// => Outputs result
}
// Scenario 2: Invalid application - insufficient income
const invalidApplication1 = LoanApplication.create(
// => Store value in invalidApplication1
20000, // $200 income (below minimum)
// => Aggregate boundary enforced here
10000, // $100 requested
// => Domain event triggered or handled
700,
// => Cross-context interaction point
);
// => DDD tactical pattern applied
try {
invalidApplication1.approve(approvalSpec);
// => Execute method
} catch (error) {
// => Transaction boundary maintained
console.log(error.message);
// => Outputs result
// => Output: Cannot approve: Applicant income must be at least $300
}
// Scenario 3: Invalid application - high debt-to-income ratio
const invalidApplication2 = LoanApplication.create(
// => Store value in invalidApplication2
100000, // $1000 income
// => Domain model consistency maintained
50000, // $500 requested (50% ratio - too high)
// => Communicates domain intent
700,
// => Domain operation executes here
);
// => Modifies aggregate internal state
try {
// => Validates business rule
invalidApplication2.approve(approvalSpec);
// => Execute method
} catch (error) {
// => Enforces invariant
console.log(error.message);
// => Outputs result
// => Output: Cannot approve: Debt-to-income ratio exceeds maximum (43%)
}Key Takeaway: Validation Specifications encapsulate complex business rules for state transitions. Domain methods accept Specification parameters, delegating validation to business-rule objects. This keeps validation logic testable, reusable, and explicit rather than buried in domain entity methods.
Why It Matters: Validation Specifications enable regulatory compliance and business rule documentation. Banks must document loan approval criteria for auditors—Specification classes become living documentation of exact rules (minimum income, debt-to-income ratio, credit score thresholds). When regulations change (e.g., max debt-to-income ratio reduced from 43% to 36%), update one Specification class instead of finding all validation logic scattered across codebase. Specifications make business rules explicit, testable, and auditable.
Integration Patterns (Examples 59-60)
Example 59: Outbox Pattern for Reliable Event Publishing
Ensuring domain events are published reliably even if message broker is unavailable.
// Domain Event
class OrderConfirmedEvent {
constructor(
// => Initialize object with parameters
public readonly eventId: string,
public readonly orderId: string,
public readonly occurredAt: Date,
) {}
}
// Outbox Entry - stores events in database
class OutboxEntry {
constructor(
// => Initialize object with parameters
public readonly entryId: string,
public readonly eventType: string,
public readonly eventPayload: string, // JSON serialized event
public readonly createdAt: Date,
public published: boolean = false,
) {}
// => Validates business rule
markAsPublished(): void {
this.published = true;
// => Update published state
}
// => Enforces invariant
}
// Outbox Repository
interface OutboxRepository {
// => OutboxRepository: contract definition
save(entry: OutboxEntry): void;
findUnpublished(): OutboxEntry[];
markAsPublished(entryId: string): void;
}
class InMemoryOutboxRepository implements OutboxRepository {
private entries: Map<string, OutboxEntry> = new Map();
// => Encapsulated field (not publicly accessible)
save(entry: OutboxEntry): void {
this.entries.set(entry.entryId, entry);
// => Delegates to internal method
console.log(`Outbox entry saved: ${entry.entryId}`);
// => Outputs result
}
findUnpublished(): OutboxEntry[] {
return Array.from(this.entries.values()).filter((e) => !e.published);
}
markAsPublished(entryId: string): void {
const entry = this.entries.get(entryId);
// => Store value in entry
if (entry) {
entry.markAsPublished();
// => Execute method
console.log(`Outbox entry ${entryId} marked published`);
// => Outputs result
}
}
}
// Application Service - saves to outbox instead of publishing directly
class ConfirmOrderService {
constructor(
// => Initialize object with parameters
private readonly orderRepo: OrderRepository,
// => Encapsulated state, not directly accessible
private readonly outboxRepo: OutboxRepository,
// => Encapsulated state, not directly accessible
) {}
confirmOrder(orderId: string): void {
// Step 1: Load and confirm order
const order = this.orderRepo.findById(orderId);
// => Store value in order
if (!order) {
throw new Error("Order not found");
// => Raise domain exception
}
order.confirm();
// => Execute method
this.orderRepo.save(order);
// => Delegates to internal method
// => Order confirmed and saved
// Step 2: Save event to outbox (same transaction as order update)
const event = new OrderConfirmedEvent(`EVT-${Date.now()}`, orderId, new Date());
// => Store value in event
const outboxEntry = new OutboxEntry(`OUT-${Date.now()}`, "OrderConfirmed", JSON.stringify(event), new Date());
// => Store value in outboxEntry
this.outboxRepo.save(outboxEntry);
// => Delegates to internal method
// => Event saved to outbox (transactionally consistent)
console.log(`Order ${orderId} confirmed, event saved to outbox`);
// => Outputs result
}
}
// => Communicates domain intent
// Background worker - publishes events from outbox
class OutboxPublisher {
constructor(
// => Initialize object with parameters
private readonly outboxRepo: OutboxRepository,
// => Encapsulated state, not directly accessible
private readonly eventPublisher: EventPublisher,
// => Encapsulated state, not directly accessible
) {}
publishPendingEvents(): void {
// => Background job: publish unpublished events
const unpublished = this.outboxRepo.findUnpublished();
// => Store value in unpublished
console.log(`Found ${unpublished.length} unpublished events`);
// => Outputs result
unpublished.forEach((entry) => {
// => forEach: process collection elements
try {
const event = JSON.parse(entry.eventPayload);
// => Store value in event
this.eventPublisher.publish(event);
// => Delegates to internal method
// => Publish to message broker
this.outboxRepo.markAsPublished(entry.entryId);
// => Delegates to internal method
// => Mark as published in outbox
} catch (error) {
// => Validates business rule
console.log(`Failed to publish ${entry.entryId}: ${error.message}`);
// => Outputs result
// => Retry on next poll
}
// => Enforces invariant
});
}
}
// Supporting classes
class Order {
constructor(
// => Initialize object with parameters
private readonly orderId: string,
// => Encapsulated state, not directly accessible
private status: string = "PENDING",
// => Encapsulated field (not publicly accessible)
) {}
confirm(): void {
this.status = "CONFIRMED";
// => Update status state
}
getOrderId(): string {
return this.orderId;
// => Return result to caller
}
}
interface OrderRepository {
// => OrderRepository: contract definition
findById(orderId: string): Order | null;
save(order: Order): void;
}
class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map();
// => Encapsulated field (not publicly accessible)
findById(orderId: string): Order | null {
return this.orders.get(orderId) || null;
// => Return result to caller
}
save(order: Order): void {
this.orders.set(order.getOrderId(), order);
// => Delegates to internal method
}
}
// => Communicates domain intent
interface EventPublisher {
// => EventPublisher: contract definition
publish(event: any): void;
}
class MockEventPublisher implements EventPublisher {
private publishedEvents: any[] = [];
// => Encapsulated field (not publicly accessible)
publish(event: any): void {
this.publishedEvents.push(event);
// => Delegates to internal method
console.log(`Event published to message broker: ${event.eventId}`);
// => Outputs result
}
getPublishedCount(): number {
return this.publishedEvents.length;
// => Return result to caller
}
// => Validates business rule
}
// => Enforces invariant
// Usage - Outbox pattern ensures reliable event publishing
const orderRepo = new InMemoryOrderRepository();
// => Store value in orderRepo
const outboxRepo = new InMemoryOutboxRepository();
// => Store value in outboxRepo
const eventPublisher = new MockEventPublisher();
// => Store value in eventPublisher
const order = new Order("O123");
// => Store value in order
orderRepo.save(order);
// => Execute method
const confirmService = new ConfirmOrderService(orderRepo, outboxRepo);
// => Store value in confirmService
confirmService.confirmOrder("O123");
// => Output: Outbox entry saved: OUT-[timestamp]
// => Output: Order O123 confirmed, event saved to outbox
// Background worker publishes events
const outboxPublisher = new OutboxPublisher(outboxRepo, eventPublisher);
// => Store value in outboxPublisher
outboxPublisher.publishPendingEvents();
// => Output: Found 1 unpublished events
// => Output: Event published to message broker: EVT-[timestamp]
// => Output: Outbox entry OUT-[timestamp] marked published
console.log(`Total events published: ${eventPublisher.getPublishedCount()}`);
// => Outputs result
// => Output: Total events published: 1Key Takeaway: Outbox pattern stores domain events in database within same transaction as aggregate changes, then publishes them asynchronously via background worker. This ensures events are never lost even if message broker is unavailable, achieving eventual consistency with guaranteed delivery.
Why It Matters: Direct event publishing to message brokers can lose events during failures. If Kafka is down when order confirmed, event never publishes, causing downstream systems (shipping, inventory) to miss critical state changes. Outbox pattern solves this: events saved to database (same transaction as order), background worker retries until published. Major platforms use Outbox for financial transactions where losing events means financial loss. Trade-off: slight delay (polling interval) vs. guaranteed delivery.
Example 60: API Gateway Integration with Anti-Corruption Layer
Integrating with external REST APIs while protecting domain model from external contracts.
// External API Response (third-party format we don't control)
interface ExternalProductAPIResponse {
product_id: string; // => Snake case naming
product_name: string;
// => Domain operation executes here
price_in_cents: number;
// => Modifies aggregate internal state
available_qty: number;
// => Validates business rule
category_code: string;
// => Enforces invariant
}
// Our Domain Model (our ubiquitous language)
class Product {
private constructor(
// => Initialize object with parameters
private readonly productId: string,
// => Encapsulated state, not directly accessible
private readonly name: string,
// => Encapsulated state, not directly accessible
private readonly price: Money,
// => Encapsulated state, not directly accessible
private readonly stockQuantity: number,
// => Encapsulated state, not directly accessible
private readonly category: ProductCategory,
// => Encapsulated state, not directly accessible
) {}
static create(
// => Aggregate boundary enforced here
productId: string,
// => Domain event triggered or handled
name: string,
// => Cross-context interaction point
price: Money,
// => DDD tactical pattern applied
stockQuantity: number,
// => Invariant validation executed
category: ProductCategory,
// => Transaction boundary maintained
): Product {
return new Product(productId, name, price, stockQuantity, category);
// => Return result to caller
}
getProductId(): string {
return this.productId;
// => Return result to caller
}
// => Communicates domain intent
getName(): string {
return this.name;
// => Return result to caller
}
getPrice(): Money {
return this.price;
// => Return result to caller
}
isAvailable(): boolean {
return this.stockQuantity > 0;
// => Return result to caller
}
// => Validates business rule
}
// => Enforces invariant
class Money {
constructor(
// => Initialize object with parameters
private readonly amount: number,
// => Encapsulated state, not directly accessible
private readonly currency: string,
// => Encapsulated state, not directly accessible
) {}
getAmount(): number {
return this.amount;
// => Return result to caller
}
getCurrency(): string {
return this.currency;
// => Return result to caller
}
}
enum ProductCategory {
ELECTRONICS = "ELECTRONICS",
// => ELECTRONICS: maps to string value "ELECTRONICS"
CLOTHING = "CLOTHING",
// => CLOTHING: maps to string value "CLOTHING"
BOOKS = "BOOKS",
// => BOOKS: maps to string value "BOOKS"
}
// Anti-Corruption Layer - translates external API to our domain
class ProductAPIAdapter {
constructor(private readonly apiClient: ExternalProductAPI) {}
// => Initialize object with parameters
async fetchProduct(productId: string): Promise<Product> {
// => ACL method using domain types
const externalResponse = await this.apiClient.getProduct(productId);
// => Call external API (uses their format)
return this.translateToDomain(externalResponse);
// => Translate to our domain model
}
private translateToDomain(response: ExternalProductAPIResponse): Product {
// => Internal logic (not part of public API)
// => ACL translation logic
const money = new Money(response.price_in_cents, "USD");
// => Convert external price to our Money value object
const category = this.translateCategory(response.category_code);
// => Map external category codes to our enum
return Product.create(response.product_id, response.product_name, money, response.available_qty, category);
// => Create our domain model from external data
}
// => Communicates domain intent
private translateCategory(categoryCode: string): ProductCategory {
// => Internal logic (not part of public API)
// => Map external codes to domain enum
switch (categoryCode) {
case "ELEC":
// => Domain operation executes here
return ProductCategory.ELECTRONICS;
case "CLTH":
// => Modifies aggregate internal state
return ProductCategory.CLOTHING;
case "BOOK":
// => Validates business rule
return ProductCategory.BOOKS;
default:
// => Enforces invariant
return ProductCategory.BOOKS; // Default fallback
}
}
}
// External API Client (simulated)
interface ExternalProductAPI {
// => ExternalProductAPI: contract definition
getProduct(productId: string): Promise<ExternalProductAPIResponse>;
}
class MockExternalProductAPI implements ExternalProductAPI {
async getProduct(productId: string): Promise<ExternalProductAPIResponse> {
// Simulate external API response
return {
product_id: productId,
// => Cross-context interaction point
product_name: "Laptop Pro",
// => DDD tactical pattern applied
price_in_cents: 150000,
// => Invariant validation executed
available_qty: 10,
// => Transaction boundary maintained
category_code: "ELEC",
// => Entity state transition managed
};
}
// => Communicates domain intent
}
// Application Service - uses ACL adapter
class ProductApplicationService {
constructor(private readonly productAdapter: ProductAPIAdapter) {}
// => Initialize object with parameters
async getProductDetails(productId: string): Promise<void> {
// => Application service uses domain model
const product = await this.productAdapter.fetchProduct(productId);
// => Retrieve product via ACL
console.log(`Product: ${product.getName()}`);
// => Outputs result
console.log(`Price: ${product.getPrice().getCurrency()} ${product.getPrice().getAmount() / 100}`);
// => Outputs result
console.log(`Available: ${product.isAvailable()}`);
// => Outputs result
// => Work with our domain model, not external format
}
}
// => Validates business rule
// Usage - ACL shields domain from external API
(async () => {
// => Create data structure
const externalAPI = new MockExternalProductAPI();
// => Store value in externalAPI
const adapter = new ProductAPIAdapter(externalAPI);
// => Store value in adapter
const applicationService = new ProductApplicationService(adapter);
// => Store value in applicationService
await applicationService.getProductDetails("P123");
// => Output: Product: Laptop Pro
// => Output: Price: USD 1500
// => Output: Available: true
})();
// => Enforces invariantKey Takeaway: API Gateway integration with Anti-Corruption Layer translates external API responses into domain models, protecting ubiquitous language from external contracts. External APIs use their naming conventions and data structures; ACL adapts to our domain model, keeping domain pure.
Why It Matters: External APIs change frequently and have different domain models. When integrating with a payment processor's API, don't pollute your domain with their naming conventions ("charge," "source," "customer_id"). Build ACL that translates external responses to your Payment, PaymentMethod, and Customer domain objects. When the external API changes (e.g., deprecates "source" in favor of "payment_method"), only ACL updates—domain model unchanged. This isolation enables switching payment processors by swapping ACL implementation without touching domain logic.
Last updated January 30, 2026