# Tactical DDD Patterns Tactical DDD patterns are code-level building blocks for implementing a rich domain model. They help express domain concepts in code that mirrors how domain experts think. ## Entity ### Definition An object defined by its identity rather than its attributes. Two entities with the same attribute values but different identities are different things. ### Characteristics - Has a unique identifier that persists through state changes - Identity established at creation, immutable thereafter - Equality based on identity, not attribute values - Has a lifecycle (created, modified, potentially deleted) - Contains behavior relevant to the domain concept it represents ### When to Use - The object represents something tracked over time - "Is this the same one?" is a meaningful question - The object needs to be referenced from other parts of the system - State changes are important to track ### Implementation ```typescript // Entity with identity and behavior class Order { private readonly _id: OrderId; private _status: OrderStatus; private _items: OrderItem[]; private _shippingAddress: Address; constructor(id: OrderId, items: OrderItem[], shippingAddress: Address) { this._id = id; this._items = items; this._shippingAddress = shippingAddress; this._status = OrderStatus.Pending; } get id(): OrderId { return this._id; } // Behavior, not just data access confirm(): void { if (this._items.length === 0) { throw new EmptyOrderError(this._id); } this._status = OrderStatus.Confirmed; } ship(trackingNumber: TrackingNumber): void { if (this._status !== OrderStatus.Confirmed) { throw new InvalidOrderStateError(this._id, this._status, 'ship'); } this._status = OrderStatus.Shipped; // Domain event raised } addItem(item: OrderItem): void { if (this._status !== OrderStatus.Pending) { throw new OrderModificationError(this._id); } this._items.push(item); } // Identity-based equality equals(other: Order): boolean { return this._id.equals(other._id); } } // Strongly-typed identity class OrderId { constructor(private readonly value: string) { if (!value || value.trim() === '') { throw new InvalidOrderIdError(); } } equals(other: OrderId): boolean { return this.value === other.value; } toString(): string { return this.value; } } ``` ### Entity vs Data Structure ```typescript // Bad: Anemic entity (data structure) class Order { id: string; status: string; items: Item[]; // Only getters/setters, no behavior } // Good: Rich entity with behavior class Order { private _id: OrderId; private _status: OrderStatus; private _items: OrderItem[]; confirm(): void { /* enforces rules */ } cancel(reason: CancellationReason): void { /* enforces rules */ } addItem(item: OrderItem): void { /* enforces rules */ } } ``` ## Value Object ### Definition An object defined entirely by its attributes. Two value objects with the same attributes are interchangeable. Has no identity. ### Characteristics - Immutable - once created, never changes - Equality based on attributes, not identity - Self-validating - always in a valid state - Side-effect free - methods return new instances - Conceptually whole - attributes form a complete concept ### When to Use - The concept has no lifecycle or identity - "Are these the same?" means "do they have the same values?" - Measurement, description, or quantification - Combinations of attributes that belong together ### Implementation ```typescript // Value Object: Money class Money { private constructor( private readonly amount: number, private readonly currency: Currency ) {} // Factory method with validation static of(amount: number, currency: Currency): Money { if (amount < 0) { throw new NegativeMoneyError(amount); } return new Money(amount, currency); } // Immutable operations - return new instances add(other: Money): Money { this.ensureSameCurrency(other); return Money.of(this.amount + other.amount, this.currency); } subtract(other: Money): Money { this.ensureSameCurrency(other); return Money.of(this.amount - other.amount, this.currency); } multiply(factor: number): Money { return Money.of(this.amount * factor, this.currency); } // Value-based equality equals(other: Money): boolean { return this.amount === other.amount && this.currency.equals(other.currency); } private ensureSameCurrency(other: Money): void { if (!this.currency.equals(other.currency)) { throw new CurrencyMismatchError(this.currency, other.currency); } } } // Value Object: Address class Address { private constructor( readonly street: string, readonly city: string, readonly postalCode: string, readonly country: Country ) {} static create(street: string, city: string, postalCode: string, country: Country): Address { if (!street || !city || !postalCode) { throw new InvalidAddressError(); } if (!country.validatePostalCode(postalCode)) { throw new InvalidPostalCodeError(postalCode, country); } return new Address(street, city, postalCode, country); } // Returns new instance with modified value withStreet(newStreet: string): Address { return Address.create(newStreet, this.city, this.postalCode, this.country); } equals(other: Address): boolean { return this.street === other.street && this.city === other.city && this.postalCode === other.postalCode && this.country.equals(other.country); } } // Value Object: DateRange class DateRange { private constructor( readonly start: Date, readonly end: Date ) {} static create(start: Date, end: Date): DateRange { if (end < start) { throw new InvalidDateRangeError(start, end); } return new DateRange(start, end); } contains(date: Date): boolean { return date >= this.start && date <= this.end; } overlaps(other: DateRange): boolean { return this.start <= other.end && this.end >= other.start; } durationInDays(): number { return Math.floor((this.end.getTime() - this.start.getTime()) / (1000 * 60 * 60 * 24)); } } ``` ### Common Value Objects | Domain | Value Objects | |--------|--------------| | **E-commerce** | Money, Price, Quantity, SKU, Address, PhoneNumber | | **Healthcare** | BloodPressure, Dosage, DateRange, PatientId | | **Finance** | AccountNumber, IBAN, TaxId, Percentage | | **Shipping** | Weight, Dimensions, TrackingNumber, PostalCode | | **General** | Email, URL, PhoneNumber, Name, Coordinates | ## Aggregate ### Definition A cluster of entities and value objects with defined boundaries. Has an aggregate root entity that serves as the single entry point. External objects can only reference the root. ### Characteristics - Defines a transactional consistency boundary - Aggregate root is the only externally accessible object - Enforces invariants across the cluster - Loaded and saved as a unit - Other aggregates referenced by identity only ### Design Rules 1. **Protect invariants** - All rules that must be consistent are inside the boundary 2. **Small aggregates** - Prefer single-entity aggregates; add children only when invariants require 3. **Reference by identity** - Never hold direct references to other aggregates 4. **Update one per transaction** - Eventual consistency between aggregates 5. **Design around invariants** - Identify what must be immediately consistent ### Implementation ```typescript // Aggregate: Order (root) with OrderItems (child entities) class Order { private readonly _id: OrderId; private _items: Map; private _status: OrderStatus; // Invariant: Order total cannot exceed credit limit private _creditLimit: Money; private constructor( id: OrderId, creditLimit: Money ) { this._id = id; this._items = new Map(); this._status = OrderStatus.Draft; this._creditLimit = creditLimit; } static create(id: OrderId, creditLimit: Money): Order { return new Order(id, creditLimit); } // All modifications go through aggregate root addItem(productId: ProductId, quantity: Quantity, unitPrice: Money): void { this.ensureCanModify(); const newItem = OrderItem.create(productId, quantity, unitPrice); const projectedTotal = this.calculateTotalWith(newItem); // Invariant enforcement if (projectedTotal.isGreaterThan(this._creditLimit)) { throw new CreditLimitExceededError(projectedTotal, this._creditLimit); } this._items.set(productId, newItem); } removeItem(productId: ProductId): void { this.ensureCanModify(); this._items.delete(productId); } updateItemQuantity(productId: ProductId, newQuantity: Quantity): void { this.ensureCanModify(); const item = this._items.get(productId); if (!item) { throw new ItemNotFoundError(productId); } const updatedItem = item.withQuantity(newQuantity); const projectedTotal = this.calculateTotalWithUpdate(productId, updatedItem); if (projectedTotal.isGreaterThan(this._creditLimit)) { throw new CreditLimitExceededError(projectedTotal, this._creditLimit); } this._items.set(productId, updatedItem); } submit(): OrderSubmitted { if (this._items.size === 0) { throw new EmptyOrderError(); } this._status = OrderStatus.Submitted; return new OrderSubmitted(this._id, this.total(), new Date()); } // Read-only access to child entities get items(): ReadonlyArray { return Array.from(this._items.values()); } total(): Money { return this.items.reduce( (sum, item) => sum.add(item.subtotal()), Money.zero(Currency.USD) ); } private ensureCanModify(): void { if (this._status !== OrderStatus.Draft) { throw new OrderNotModifiableError(this._id, this._status); } } private calculateTotalWith(newItem: OrderItem): Money { return this.total().add(newItem.subtotal()); } private calculateTotalWithUpdate(productId: ProductId, updatedItem: OrderItem): Money { const currentItem = this._items.get(productId)!; return this.total().subtract(currentItem.subtotal()).add(updatedItem.subtotal()); } } // Child entity - only accessible through aggregate root class OrderItem { private constructor( private readonly _productId: ProductId, private _quantity: Quantity, private readonly _unitPrice: Money ) {} static create(productId: ProductId, quantity: Quantity, unitPrice: Money): OrderItem { return new OrderItem(productId, quantity, unitPrice); } get productId(): ProductId { return this._productId; } get quantity(): Quantity { return this._quantity; } get unitPrice(): Money { return this._unitPrice; } subtotal(): Money { return this._unitPrice.multiply(this._quantity.value); } withQuantity(newQuantity: Quantity): OrderItem { return new OrderItem(this._productId, newQuantity, this._unitPrice); } } ``` ### Aggregate Reference Patterns ```typescript // Bad: Direct object reference across aggregates class Order { private customer: Customer; // Holds the entire aggregate! } // Good: Reference by identity class Order { private customerId: CustomerId; // If customer data needed, load separately getCustomerAddress(customerRepository: CustomerRepository): Address { const customer = customerRepository.findById(this.customerId); return customer.shippingAddress; } } ``` ## Domain Event ### Definition A record of something significant that happened in the domain. Captures state changes that domain experts care about. ### Characteristics - Named in past tense (OrderPlaced, PaymentReceived) - Immutable - records historical fact - Contains all relevant data about what happened - Published after state change is committed - May trigger reactions in same or different bounded contexts ### When to Use - Domain experts talk about "when X happens, Y should happen" - Need to communicate changes across aggregate boundaries - Maintaining an audit trail - Implementing eventual consistency - Integration with other bounded contexts ### Implementation ```typescript // Base domain event abstract class DomainEvent { readonly occurredAt: Date; readonly eventId: string; constructor() { this.occurredAt = new Date(); this.eventId = generateUUID(); } abstract get eventType(): string; } // Specific domain events class OrderPlaced extends DomainEvent { constructor( readonly orderId: OrderId, readonly customerId: CustomerId, readonly totalAmount: Money, readonly items: ReadonlyArray ) { super(); } get eventType(): string { return 'order.placed'; } } class OrderShipped extends DomainEvent { constructor( readonly orderId: OrderId, readonly trackingNumber: TrackingNumber, readonly carrier: string, readonly estimatedDelivery: Date ) { super(); } get eventType(): string { return 'order.shipped'; } } class PaymentReceived extends DomainEvent { constructor( readonly orderId: OrderId, readonly amount: Money, readonly paymentMethod: PaymentMethod, readonly transactionId: string ) { super(); } get eventType(): string { return 'payment.received'; } } // Entity raising events class Order { private _domainEvents: DomainEvent[] = []; submit(): void { // State change this._status = OrderStatus.Submitted; // Raise event this._domainEvents.push( new OrderPlaced( this._id, this._customerId, this.total(), this.itemSnapshots() ) ); } pullDomainEvents(): DomainEvent[] { const events = [...this._domainEvents]; this._domainEvents = []; return events; } } // Event handler class OrderPlacedHandler { constructor( private inventoryService: InventoryService, private emailService: EmailService ) {} async handle(event: OrderPlaced): Promise { // Reserve inventory (different aggregate) await this.inventoryService.reserveItems(event.items); // Send confirmation email await this.emailService.sendOrderConfirmation( event.customerId, event.orderId, event.totalAmount ); } } ``` ### Event Publishing Patterns ```typescript // Pattern 1: Collect and dispatch after save class OrderApplicationService { async placeOrder(command: PlaceOrderCommand): Promise { const order = Order.create(command); await this.orderRepository.save(order); // Dispatch events after successful save const events = order.pullDomainEvents(); await this.eventDispatcher.dispatchAll(events); return order.id; } } // Pattern 2: Outbox pattern (reliable publishing) class OrderApplicationService { async placeOrder(command: PlaceOrderCommand): Promise { await this.unitOfWork.transaction(async () => { const order = Order.create(command); await this.orderRepository.save(order); // Save events to outbox in same transaction const events = order.pullDomainEvents(); await this.outbox.saveEvents(events); }); // Separate process publishes from outbox return order.id; } } ``` ## Repository ### Definition Mediates between the domain and data mapping layers. Provides collection-like interface for accessing aggregates. ### Characteristics - One repository per aggregate root - Interface defined in domain layer, implementation in infrastructure - Returns fully reconstituted aggregates - Abstracts persistence concerns from domain ### Interface Design ```typescript // Domain layer interface interface OrderRepository { findById(id: OrderId): Promise; save(order: Order): Promise; delete(order: Order): Promise; // Domain-specific queries findPendingOrdersFor(customerId: CustomerId): Promise; findOrdersToShipBefore(deadline: Date): Promise; } // Infrastructure implementation class PostgresOrderRepository implements OrderRepository { constructor(private db: Database) {} async findById(id: OrderId): Promise { const row = await this.db.query( 'SELECT * FROM orders WHERE id = $1', [id.toString()] ); if (!row) return null; const items = await this.db.query( 'SELECT * FROM order_items WHERE order_id = $1', [id.toString()] ); return this.reconstitute(row, items); } async save(order: Order): Promise { await this.db.transaction(async (tx) => { await tx.query( 'INSERT INTO orders (id, status, customer_id) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET status = $2', [order.id.toString(), order.status, order.customerId.toString()] ); // Save items for (const item of order.items) { await tx.query( 'INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES ($1, $2, $3, $4) ON CONFLICT DO UPDATE...', [order.id.toString(), item.productId.toString(), item.quantity.value, item.unitPrice.amount] ); } }); } private reconstitute(orderRow: any, itemRows: any[]): Order { // Rebuild aggregate from persistence data return Order.reconstitute({ id: OrderId.from(orderRow.id), status: OrderStatus[orderRow.status], customerId: CustomerId.from(orderRow.customer_id), items: itemRows.map(row => OrderItem.reconstitute({ productId: ProductId.from(row.product_id), quantity: Quantity.of(row.quantity), unitPrice: Money.of(row.unit_price, Currency.USD) })) }); } } ``` ### Repository vs DAO ```typescript // DAO: Data-centric, returns raw data interface OrderDao { findById(id: string): Promise; findItems(orderId: string): Promise; insert(row: OrderRow): Promise; } // Repository: Domain-centric, returns aggregates interface OrderRepository { findById(id: OrderId): Promise; save(order: Order): Promise; } ``` ## Domain Service ### Definition Stateless operations that represent domain concepts but don't naturally belong to any entity or value object. ### When to Use - The operation involves multiple aggregates - The operation represents a domain concept - Putting the operation on an entity would create awkward dependencies - The operation is stateless ### Examples ```typescript // Domain Service: Transfer money between accounts class MoneyTransferService { transfer( from: Account, to: Account, amount: Money ): TransferResult { // Involves two aggregates // Neither account should "own" this operation if (!from.canWithdraw(amount)) { return TransferResult.insufficientFunds(); } from.withdraw(amount); to.deposit(amount); return TransferResult.success( new MoneyTransferred(from.id, to.id, amount) ); } } // Domain Service: Calculate shipping cost class ShippingCostCalculator { constructor( private rateProvider: ShippingRateProvider ) {} calculate( items: OrderItem[], destination: Address, shippingMethod: ShippingMethod ): Money { const totalWeight = items.reduce( (sum, item) => sum.add(item.weight), Weight.zero() ); const rate = this.rateProvider.getRate( destination.country, shippingMethod ); return rate.calculateFor(totalWeight); } } // Domain Service: Check inventory availability class InventoryAvailabilityService { constructor( private inventoryRepository: InventoryRepository ) {} checkAvailability( items: Array<{ productId: ProductId; quantity: Quantity }> ): AvailabilityResult { const unavailable: ProductId[] = []; for (const { productId, quantity } of items) { const inventory = this.inventoryRepository.findByProductId(productId); if (!inventory || !inventory.hasAvailable(quantity)) { unavailable.push(productId); } } return unavailable.length === 0 ? AvailabilityResult.allAvailable() : AvailabilityResult.someUnavailable(unavailable); } } ``` ### Domain Service vs Application Service ```typescript // Domain Service: Domain logic, domain types, stateless class PricingService { calculateDiscountedPrice(product: Product, customer: Customer): Money { const basePrice = product.price; const discount = customer.membershipLevel.discountPercentage; return basePrice.applyDiscount(discount); } } // Application Service: Orchestration, use cases, transaction boundary class OrderApplicationService { constructor( private orderRepository: OrderRepository, private pricingService: PricingService, private eventPublisher: EventPublisher ) {} async createOrder(command: CreateOrderCommand): Promise { const customer = await this.customerRepository.findById(command.customerId); const order = Order.create(command.orderId, customer.id); for (const item of command.items) { const product = await this.productRepository.findById(item.productId); const price = this.pricingService.calculateDiscountedPrice(product, customer); order.addItem(item.productId, item.quantity, price); } await this.orderRepository.save(order); await this.eventPublisher.publish(order.pullDomainEvents()); return order.id; } } ``` ## Factory ### Definition Encapsulates complex object or aggregate creation logic. Creates objects in a valid state. ### When to Use - Construction logic is complex - Multiple ways to create the same type of object - Creation involves other objects or services - Need to enforce invariants at creation time ### Implementation ```typescript // Factory as static method class Order { static create(customerId: CustomerId, creditLimit: Money): Order { return new Order( OrderId.generate(), customerId, creditLimit, OrderStatus.Draft, [] ); } static reconstitute(data: OrderData): Order { // For rebuilding from persistence return new Order( data.id, data.customerId, data.creditLimit, data.status, data.items ); } } // Factory as separate class class OrderFactory { constructor( private creditLimitService: CreditLimitService, private idGenerator: IdGenerator ) {} async createForCustomer(customerId: CustomerId): Promise { const creditLimit = await this.creditLimitService.getLimit(customerId); const orderId = this.idGenerator.generate(); return Order.create(orderId, customerId, creditLimit); } createFromQuote(quote: Quote): Order { const order = Order.create( this.idGenerator.generate(), quote.customerId, quote.creditLimit ); for (const item of quote.items) { order.addItem(item.productId, item.quantity, item.agreedPrice); } return order; } } // Builder pattern for complex construction class OrderBuilder { private customerId?: CustomerId; private items: OrderItemData[] = []; private shippingAddress?: Address; private billingAddress?: Address; forCustomer(customerId: CustomerId): this { this.customerId = customerId; return this; } withItem(productId: ProductId, quantity: Quantity, price: Money): this { this.items.push({ productId, quantity, price }); return this; } shippingTo(address: Address): this { this.shippingAddress = address; return this; } billingTo(address: Address): this { this.billingAddress = address; return this; } build(): Order { if (!this.customerId) throw new Error('Customer required'); if (!this.shippingAddress) throw new Error('Shipping address required'); if (this.items.length === 0) throw new Error('At least one item required'); const order = Order.create(this.customerId); order.setShippingAddress(this.shippingAddress); order.setBillingAddress(this.billingAddress ?? this.shippingAddress); for (const item of this.items) { order.addItem(item.productId, item.quantity, item.price); } return order; } } ```