Skip to content
AyoKoding

Intermediate

This section covers Examples 26-55, building on the tactical building blocks from the beginner section. Each example is self-contained with all necessary type definitions; references to "the Order aggregate from Example 16" are conceptual orientation only — the code compiles standalone.

Specifications and Factories (Examples 26-29)

Example 26: Specification pattern — encapsulating a business rule

A Specification wraps a single business rule in a named, testable object. Instead of scattering if (order.total > 100 && order.status == CONFIRMED) across service methods, the rule lives in one place with a name that domain experts recognise.

graph TD
    A["Client Code"]:::blue --> B["Specification#40;isSatisfiedBy#41;"]:::orange
    B --> C{"Rule Passes?"}:::orange
    C -->|Yes| D["Proceed"]:::teal
    C -->|No| E["Reject / Filter"]:::brown
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef brown fill:#CA9161,stroke:#000000,color:#FFFFFF,stroke-width:2px

Java:

import java.math.BigDecimal;                                          // => namespace/package import
import java.util.List;                                                // => namespace/package import
 
// ── supporting types ──────────────────────────────────────────────────────────
// These minimal types make the example self-contained; no external dependency needed
enum OrderStatus { PENDING, CONFIRMED, SHIPPED, CANCELLED }           // => enum OrderStatus
// => Four lifecycle states; only CONFIRMED orders are eligible for the discount rule
 
record OrderId(String value) {}                                       // => record OrderId
// => Strongly-typed ID; wraps String to prevent mixing with other IDs
record Money(BigDecimal amount, String currency) {}                   // => record Money
// => Value Object pair: amount is meaningless without its currency
 
class Order {                                                         // => class Order
    private final OrderId id;                                         // => id field
    // => private final: ID assigned once, never mutated
    private final Money total;                                        // => total field
    // => private final: total is immutable after construction
    private final OrderStatus status;                                 // => status field
    // => Immutable fields; Order only exposes getters — no public setters
 
    Order(OrderId id, Money total, OrderStatus status) {              // => Order() called
        this.id = id; this.total = total; this.status = status;       // => this.id assigned
        // => All three fields required; no partial construction
    }
    public Money getTotal()       { return total; }   // => Read-only accessor
    public OrderStatus getStatus(){ return status; }  // => Read-only accessor
}
 
// ── Specification interface ───────────────────────────────────────────────────
// A Specification encapsulates one cohesive business rule
// => Generic: works for any domain object type T
// => Single method makes this a functional interface; lambdas can implement it
interface Specification<T> {                                          // => interface Specification
    boolean isSatisfiedBy(T candidate); // => True = rule passes for this candidate
    // => False = candidate does not satisfy this rule
}
 
// ── Concrete specification: eligible for discount ────────────────────────────
// The rule "total >= 100 AND status is CONFIRMED" has a name the domain expert recognises
class EligibleForDiscountSpec implements Specification<Order> {       // => class EligibleForDiscountSpec
    private static final BigDecimal THRESHOLD = new BigDecimal("100.00"); // => THRESHOLD declared
    // => Business rule threshold defined as a named constant, not a magic literal
 
    @Override                                                         // => expression
    public boolean isSatisfiedBy(Order order) {                       // => isSatisfiedBy method
        boolean bigEnough = order.getTotal().amount().compareTo(THRESHOLD) >= 0; // => order.getTotal() called
        // => BigDecimal.compareTo: returns 0 if equal, negative if less, positive if greater
        // => >= 0 means amount equals or exceeds threshold
        boolean confirmed = order.getStatus() == OrderStatus.CONFIRMED; // => order.getStatus() called
        // => Only confirmed orders are eligible; pending orders have not yet committed
        return bigEnough && confirmed;                                // => returns bigEnough && confirmed
        // => Both conditions must hold; short-circuits if bigEnough is false
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var spec  = new EligibleForDiscountSpec();                            // => spec initialised
// => spec is stateless; safe to share or re-use across calls
var order = new Order(new OrderId("O1"),                              // => order initialised
                      new Money(new BigDecimal("120.00"), "USD"),     // => expression
                      OrderStatus.CONFIRMED);                         // => expression
// => order: 120 USD, CONFIRMED — satisfies both conditions
 
boolean eligible = spec.isSatisfiedBy(order);                         // => spec.isSatisfiedBy() called
// => true: 120 >= 100 and status is CONFIRMED
 
var smallOrder = new Order(new OrderId("O2"),                         // => smallOrder initialised
                           new Money(new BigDecimal("50.00"), "USD"), // => expression
                           OrderStatus.CONFIRMED);                    // => expression
// => smallOrder: 50 USD, CONFIRMED — fails the threshold condition
boolean notEligible = spec.isSatisfiedBy(smallOrder);                 // => spec.isSatisfiedBy() called
// => false: 50 < 100, so bigEnough is false
 
// Filter a list using the specification
List<Order> orders = List.of(order, smallOrder);                      // => List.of() called
// => Both orders in the list
List<Order> discountable = orders.stream()                            // => orders.stream() called
    .filter(spec::isSatisfiedBy)  // => Method reference: passes order to isSatisfiedBy
    .toList();                    // => [order] — only the 120 USD confirmed order qualifies
// => smallOrder excluded because it failed the threshold

Kotlin:

import java.math.BigDecimal                                           // => namespace/package import
 
// ── supporting types ──────────────────────────────────────────────────────────
enum class OrderStatus { PENDING, CONFIRMED, SHIPPED, CANCELLED }     // => enum class
data class OrderId(val value: String)                                 // => class OrderId
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
 
data class Order(val id: OrderId, val total: Money, val status: OrderStatus) // => class Order
 
// ── Specification as functional interface ─────────────────────────────────────
// Kotlin: fun interface allows SAM conversion — lambda can stand in for Specification
fun interface Specification<T> {                                      // => interface Specification
    fun isSatisfiedBy(candidate: T): Boolean // => Single abstract method
}
 
// ── Concrete specification ─────────────────────────────────────────────────────
class EligibleForDiscountSpec : Specification<Order> {                // => class EligibleForDiscountSpec
    private val threshold = BigDecimal("100.00")                      // => threshold declared
    // => Rule lives here; no scattering across service methods
 
    override fun isSatisfiedBy(candidate: Order): Boolean {           // => isSatisfiedBy method
        val bigEnough  = candidate.total.amount >= threshold // => Kotlin operator overload on BigDecimal
        val confirmed  = candidate.status == OrderStatus.CONFIRMED    // => confirmed initialised
        return bigEnough && confirmed // => Both conditions required
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val spec      = EligibleForDiscountSpec()                             // => spec initialised
val confirmed = Order(OrderId("O1"), Money(BigDecimal("120.00"), "USD"), OrderStatus.CONFIRMED) // => confirmed initialised
val small     = Order(OrderId("O2"), Money(BigDecimal("50.00"),  "USD"), OrderStatus.CONFIRMED) // => small initialised
 
val eligible    = spec.isSatisfiedBy(confirmed) // => true
val notEligible = spec.isSatisfiedBy(small)     // => false
 
val orders = listOf(confirmed, small)                                 // => orders initialised
val discountable = orders.filter(spec::isSatisfiedBy) // => [confirmed]
// => Kotlin filter accepts any (Order) -> Boolean; method reference works directly

C#:

using System;                                                         // => namespace/package import
using System.Collections.Generic;                                     // => namespace/package import
using System.Linq;                                                    // => namespace/package import
 
// ── supporting types ──────────────────────────────────────────────────────────
public enum OrderStatus { Pending, Confirmed, Shipped, Cancelled }    // => OrderStatus field
public record OrderId(string Value);                                  // => record OrderId
public record Money(decimal Amount, string Currency);                 // => record Money
public record Order(OrderId Id, Money Total, OrderStatus Status);     // => record Order
 
// ── Specification interface ────────────────────────────────────────────────────
// C# uses generic interface; lambda or class can implement
public interface ISpecification<T>                                    // => interface ISpecification
{
    bool IsSatisfiedBy(T candidate); // => Returns true when candidate meets the rule
}
 
// ── Concrete specification ─────────────────────────────────────────────────────
public class EligibleForDiscountSpec : ISpecification<Order>          // => class EligibleForDiscountSpec
{
    private const decimal Threshold = 100m;                           // => Threshold declared
    // => Business rule centralised; easy to unit-test in isolation
 
    public bool IsSatisfiedBy(Order order)                            // => IsSatisfiedBy method
    {
        bool bigEnough = order.Total.Amount >= Threshold; // => >= 100 USD
        bool confirmed = order.Status == OrderStatus.Confirmed;       // => expression
        return bigEnough && confirmed; // => Both must hold
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var spec      = new EligibleForDiscountSpec();                        // => spec initialised
var confirmed = new Order(new OrderId("O1"), new Money(120m, "USD"), OrderStatus.Confirmed); // => confirmed initialised
var small     = new Order(new OrderId("O2"), new Money(50m,  "USD"), OrderStatus.Confirmed); // => small initialised
 
bool eligible    = spec.IsSatisfiedBy(confirmed); // => true
bool notEligible = spec.IsSatisfiedBy(small);     // => false
 
var orders = new List<Order> { confirmed, small };                    // => orders initialised
var discountable = orders.Where(spec.IsSatisfiedBy).ToList(); // => [confirmed]
// => LINQ Where accepts Func<Order,bool>; IsSatisfiedBy matches the signature

Key Takeaway: A Specification object names and isolates one business rule. Callers say spec.isSatisfiedBy(order) rather than repeating the if logic everywhere.

Why It Matters: When business rules are scattered as ad-hoc if conditions inside service methods, any change to the rule requires hunting every callsite. A named Specification is a single unit of change and a single unit of test. Domain experts can read the class name EligibleForDiscountSpec and verify the rule matches their intent — removing the translation gap that causes most regression bugs during business-rule evolution.


Example 27: Composite specifications — and / or / not

Composite Specifications build complex rules from simple ones using boolean combinators. AndSpecification, OrSpecification, and NotSpecification let you compose EligibleForDiscount.and(new ActiveCustomerSpec()) without touching either original class.

graph TD
    ROOT["CompositeSpec#40;AND#41;"]:::orange
    LEFT["EligibleForDiscountSpec"]:::blue
    RIGHT["ActiveCustomerSpec"]:::teal
    ROOT --> LEFT
    ROOT --> RIGHT
 
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px

Java:

import java.math.BigDecimal;                                          // => namespace/package import
 
// ── Reuse types from Example 26 ───────────────────────────────────────────────
enum OrderStatus { PENDING, CONFIRMED }                               // => enum OrderStatus
// => Only two states needed here; composite spec applies to confirmed orders
record Money(BigDecimal amount, String currency) {}                   // => record Money
record Order(Money total, OrderStatus status, boolean customerActive) {} // => record Order
// => customerActive: true when the customer account is in good standing
 
interface Specification<T> {                                          // => interface Specification
    boolean isSatisfiedBy(T t);                                       // => expression
    // => Core method: evaluate the rule for one candidate
 
    // Default methods provide composability without subclassing
    // => Default methods added to the interface; existing implementations unchanged
    default Specification<T> and(Specification<T> other) {            // => expression
        return t -> this.isSatisfiedBy(t) && other.isSatisfiedBy(t);  // => returns t -> this.isSatisfiedBy(t) && 
        // => Returns new spec: both this AND other must be satisfied
        // => Lambda captures both specs; evaluated lazily per candidate
    }
 
    default Specification<T> or(Specification<T> other) {             // => expression
        return t -> this.isSatisfiedBy(t) || other.isSatisfiedBy(t);  // => returns t -> this.isSatisfiedBy(t) || 
        // => Returns new spec: at least one must be satisfied (short-circuits)
    }
 
    default Specification<T> not() {                                  // => expression
        return t -> !this.isSatisfiedBy(t);                           // => returns t -> !this.isSatisfiedBy(t)
        // => Returns new spec: negation of this spec; inverts every result
    }
}
 
// ── Two simple specs composed into a complex rule ────────────────────────────
// Each leaf spec is a focused lambda; single responsibility
Specification<Order> bigOrder   = o -> o.total().amount().compareTo(new BigDecimal("100")) >= 0; // => o.total() called
// => Lambda implements Specification<Order>; true when total >= 100
Specification<Order> confirmed  = o -> o.status() == OrderStatus.CONFIRMED; // => o.status() called
// => True when order has progressed past PENDING to CONFIRMED
Specification<Order> activeCustomer = o -> o.customerActive();        // => o.customerActive() called
// => True when the customer's account is active (not suspended or banned)
 
// Compose: eligible = bigOrder AND confirmed AND activeCustomer
// => and() chains return new Specification instances; originals unchanged
Specification<Order> eligible = bigOrder.and(confirmed).and(activeCustomer); // => bigOrder.and() called
// => Three independent rules chained; each testable in isolation
 
var orderA = new Order(new Money(new BigDecimal("120"), "USD"), OrderStatus.CONFIRMED, true); // => orderA initialised
// => orderA: 120 USD, CONFIRMED, active — should satisfy all three rules
var orderB = new Order(new Money(new BigDecimal("120"), "USD"), OrderStatus.CONFIRMED, false); // => orderB initialised
// => orderB: 120 USD, CONFIRMED, INACTIVE — should fail activeCustomer
 
boolean aOk = eligible.isSatisfiedBy(orderA); // => true (all three pass)
boolean bOk = eligible.isSatisfiedBy(orderB); // => false (activeCustomer fails for orderB)
 
// NOT example: flip the eligible spec to get the ineligible spec
Specification<Order> ineligible = eligible.not();                     // => eligible.not() called
// => ineligible = NOT(bigOrder AND confirmed AND activeCustomer)
boolean bIneligible = ineligible.isSatisfiedBy(orderB); // => true (orderB IS ineligible)

Kotlin:

import java.math.BigDecimal                                           // => namespace/package import
 
enum class OrderStatus { PENDING, CONFIRMED }                         // => enum class
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
data class Order(val total: Money, val status: OrderStatus, val customerActive: Boolean) // => class Order
 
// Kotlin: fun interface + extension functions for composability
fun interface Specification<T> {                                      // => interface Specification
    fun isSatisfiedBy(t: T): Boolean                                  // => isSatisfiedBy method
}
 
// Extension functions compose specs without modifying the interface
infix fun <T> Specification<T>.and(other: Specification<T>): Specification<T> = // => expression
    Specification { isSatisfiedBy(it) && other.isSatisfiedBy(it) }    // => other.isSatisfiedBy() called
// => infix: readable as  bigOrder and confirmed
 
infix fun <T> Specification<T>.or(other: Specification<T>): Specification<T> = // => expression
    Specification { isSatisfiedBy(it) || other.isSatisfiedBy(it) }    // => other.isSatisfiedBy() called
 
fun <T> Specification<T>.not(): Specification<T> =                    // => expression
    Specification { !isSatisfiedBy(it) }                              // => expression
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val bigOrder       = Specification<Order> { it.total.amount >= BigDecimal("100") } // => bigOrder initialised
val confirmed      = Specification<Order> { it.status == OrderStatus.CONFIRMED } // => confirmed initialised
val activeCustomer = Specification<Order> { it.customerActive }       // => activeCustomer initialised
 
val eligible = bigOrder and confirmed and activeCustomer              // => eligible initialised
// => Infix 'and': reads naturally as English
 
val orderA = Order(Money(BigDecimal("120"), "USD"), OrderStatus.CONFIRMED, customerActive = true) // => orderA initialised
val orderB = Order(Money(BigDecimal("120"), "USD"), OrderStatus.CONFIRMED, customerActive = false) // => orderB initialised
 
val aOk = eligible.isSatisfiedBy(orderA) // => true
val bOk = eligible.isSatisfiedBy(orderB) // => false (activeCustomer fails)
 
val ineligible = eligible.not()                                       // => ineligible initialised
val bIneligible = ineligible.isSatisfiedBy(orderB) // => true

C#:

using System;                                                         // => namespace/package import
 
public enum OrderStatus { Pending, Confirmed }                        // => OrderStatus field
public record Money(decimal Amount, string Currency);                 // => record Money
public record Order(Money Total, OrderStatus Status, bool CustomerActive); // => record Order
 
public interface ISpecification<T>                                    // => interface ISpecification
{
    bool IsSatisfiedBy(T t);                                          // => expression
 
    // Default interface methods provide combinators (C# 8+)
    ISpecification<T> And(ISpecification<T> other) =>                 // => expression
        new LambdaSpec<T>(t => IsSatisfiedBy(t) && other.IsSatisfiedBy(t)); // => other.IsSatisfiedBy() called
    // => Creates anonymous spec: both must hold
 
    ISpecification<T> Or(ISpecification<T> other) =>                  // => expression
        new LambdaSpec<T>(t => IsSatisfiedBy(t) || other.IsSatisfiedBy(t)); // => other.IsSatisfiedBy() called
 
    ISpecification<T> Not() =>                                        // => expression
        new LambdaSpec<T>(t => !IsSatisfiedBy(t));                    // => expression
}
 
// Helper: wraps a lambda as an ISpecification
public class LambdaSpec<T>(Func<T, bool> predicate) : ISpecification<T> // => class LambdaSpec
{
    // => Primary constructor (C# 12): field 'predicate' injected automatically
    public bool IsSatisfiedBy(T t) => predicate(t);                   // => IsSatisfiedBy method
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
ISpecification<Order> bigOrder       = new LambdaSpec<Order>(o => o.Total.Amount >= 100m); // => expression
ISpecification<Order> confirmed      = new LambdaSpec<Order>(o => o.Status == OrderStatus.Confirmed); // => expression
ISpecification<Order> activeCustomer = new LambdaSpec<Order>(o => o.CustomerActive); // => expression
 
var eligible = bigOrder.And(confirmed).And(activeCustomer);           // => eligible initialised
// => Three specs chained; each independently testable
 
var orderA = new Order(new Money(120m, "USD"), OrderStatus.Confirmed, CustomerActive: true); // => orderA initialised
var orderB = new Order(new Money(120m, "USD"), OrderStatus.Confirmed, CustomerActive: false); // => orderB initialised
 
bool aOk = eligible.IsSatisfiedBy(orderA); // => true
bool bOk = eligible.IsSatisfiedBy(orderB); // => false
 
var ineligible = eligible.Not();                                      // => ineligible initialised
bool bIneligible = ineligible.IsSatisfiedBy(orderB); // => true

Key Takeaway: Composing specifications with and, or, and not builds complex rules from verified, named building blocks — without touching existing classes.

Why It Matters: Complex business rules in enterprise software rarely stay simple. An initial "discount rule" grows into a multi-condition policy as the business evolves. Composite Specifications let you add conditions without modifying working code — each new Specification is open for extension and closed for modification (Open/Closed Principle). The compositional approach also makes business rules auditable: each leaf spec has a name that maps to a sentence in the requirements document.


Example 28: Factory for complex aggregate creation

A Factory method encapsulates the construction logic for an aggregate whose creation involves multiple steps, validations, or dependencies. Instead of exposing a sprawling constructor, callers use Order.create(...) and receive a fully valid aggregate with its creation event already registered.

Java:

import java.math.BigDecimal;                                          // => namespace/package import
import java.time.Instant;                                             // => namespace/package import
import java.util.*;                                                   // => namespace/package import
 
// ── Supporting types ──────────────────────────────────────────────────────────
enum OrderStatus { PENDING }                                          // => enum OrderStatus
record OrderId(String value)      { static OrderId generate() { return new OrderId(UUID.randomUUID().toString()); } } // => record OrderId
record CustomerId(String value)   {}                                  // => record CustomerId
record ProductId(String value)    {}                                  // => record ProductId
record Money(BigDecimal amount, String currency) {}                   // => record Money
 
interface DomainEvent {}                                              // => interface DomainEvent
record OrderCreated(OrderId orderId, CustomerId customerId, Instant occurredAt) implements DomainEvent {} // => record OrderCreated
 
// ── Aggregate ─────────────────────────────────────────────────────────────────
public class Order {                                                  // => Order field
    private final OrderId id;                                         // => id field
    private final CustomerId customerId;                              // => customerId field
    private final List<ProductId> lineItems;                          // => expression
    private OrderStatus status;                                       // => status field
    private final List<DomainEvent> events = new ArrayList<>();       // => List method
    // => Private constructor: external callers cannot bypass the factory
 
    private Order(OrderId id, CustomerId customerId, List<ProductId> lineItems) { // => Order method
        this.id = id;                                                 // => this.id assigned
        this.customerId = customerId;                                 // => this.customerId assigned
        this.lineItems  = List.copyOf(lineItems); // => Defensive copy; caller cannot mutate
        this.status     = OrderStatus.PENDING;                        // => this.status assigned
    }
 
    // ── Static factory method ─────────────────────────────────────────────────
    // Named factory makes intent explicit; validates, assembles, and records creation event
    public static Order create(CustomerId customerId, List<ProductId> items) { // => create method
        if (customerId == null) throw new IllegalArgumentException("CustomerId required"); // => throws if guard fails
        if (items == null || items.isEmpty()) throw new IllegalArgumentException("At least one item required"); // => throws if guard fails
        // => Business rule: an order must have items to exist
 
        OrderId newId = OrderId.generate();          // => Generate identity inside factory
        Order order = new Order(newId, customerId, items); // => Private ctor
        order.events.add(new OrderCreated(newId, customerId, Instant.now())); // => events.add() called
        // => Creation event registered inside factory; caller never needs to do this
        return order; // => Returns fully initialised, valid aggregate
    }
 
    public OrderId getId()               { return id; }               // => getId method
    public List<DomainEvent> getEvents() { return List.copyOf(events); } // => getEvents method
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var order = Order.create(                                             // => order initialised
    new CustomerId("C1"),                                             // => expression
    List.of(new ProductId("P1"), new ProductId("P2"))                 // => List.of() called
); // => order.id is a UUID; order.events has [OrderCreated]
 
// Order invalid = new Order(...); // => compile error — constructor is private

Kotlin:

import java.time.Instant                                              // => namespace/package import
import java.util.UUID                                                 // => namespace/package import
 
enum class OrderStatus { PENDING }                                    // => enum class
data class OrderId(val value: String) { companion object { fun generate() = OrderId(UUID.randomUUID().toString()) } } // => class OrderId
data class CustomerId(val value: String)                              // => class CustomerId
data class ProductId(val value: String)                               // => class ProductId
 
interface DomainEvent                                                 // => interface DomainEvent
data class OrderCreated(val orderId: OrderId, val customerId: CustomerId, val occurredAt: Instant) : DomainEvent // => class OrderCreated
 
class Order private constructor( // => private: only companion object can call
    val id: OrderId,                                                  // => expression
    val customerId: CustomerId,                                       // => expression
    val lineItems: List<ProductId>,                                   // => expression
) {                                                                   // => expression
    var status: OrderStatus = OrderStatus.PENDING                     // => expression
        private set                                                   // => expression
 
    private val _events = mutableListOf<DomainEvent>()                // => events declared
    val events: List<DomainEvent> get() = _events.toList() // => Snapshot; caller cannot mutate
 
    companion object {                                                // => expression
        // Named factory: validates, constructs, registers creation event
        fun create(customerId: CustomerId, items: List<ProductId>): Order { // => create method
            require(items.isNotEmpty()) { "At least one item required" } // => precondition check
            // => Domain rule enforced before object exists
            val id    = OrderId.generate()                            // => id initialised
            val order = Order(id, customerId, items)                  // => order initialised
            order._events.add(OrderCreated(id, customerId, Instant.now())) // => _events.add() called
            // => Creation event recorded; app service will publish after save
            return order                                              // => returns order
        }
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val order = Order.create(                                             // => order initialised
    customerId = CustomerId("C1"),                                    // => customerId assigned
    items      = listOf(ProductId("P1"), ProductId("P2"))             // => items assigned
) // => order.id is UUID; order.events = [OrderCreated(...)]
// Order(...) cannot be called directly — constructor is private

C#:

using System;                                                         // => namespace/package import
using System.Collections.Generic;                                     // => namespace/package import
 
public enum OrderStatus { Pending }                                   // => OrderStatus field
public record OrderId(string Value) { public static OrderId Generate() => new(Guid.NewGuid().ToString()); } // => record OrderId
public record CustomerId(string Value);                               // => record CustomerId
public record ProductId(string Value);                                // => record ProductId
 
public interface IDomainEvent {}                                      // => IDomainEvent field
public record OrderCreated(OrderId OrderId, CustomerId CustomerId, DateTimeOffset OccurredAt) : IDomainEvent; // => record OrderCreated
 
public class Order                                                    // => class Order
// => begins block
{
    public  OrderId           Id         { get; }                     // => Id field
    public  CustomerId        CustomerId { get; }                     // => CustomerId field
    public  IReadOnlyList<ProductId> LineItems { get; }               // => expression
    public  OrderStatus       Status     { get; private set; } = OrderStatus.Pending; // => Status field
 
    private readonly List<IDomainEvent> _events = new();              // => List method
    public  IReadOnlyList<IDomainEvent> Events => _events.AsReadOnly(); // => IReadOnlyList method
 
    private Order(OrderId id, CustomerId customerId, List<ProductId> lineItems) // => Order method
    // => begins block
    {
        // => private: callers must use Create factory
        Id         = id;                                              // => Id assigned
        CustomerId = customerId;                                      // => CustomerId assigned
        LineItems  = lineItems.AsReadOnly(); // => Defensive immutable view
    }
 
    // ── Static factory ────────────────────────────────────────────────────────
    public static Order Create(CustomerId customerId, List<ProductId> items) // => Create method
    {
        if (items == null || items.Count == 0)                        // => precondition check
            throw new ArgumentException("At least one item required"); // => throws if guard fails
        var id    = OrderId.Generate();                               // => id initialised
        var order = new Order(id, customerId, items); // => Private ctor
        order._events.Add(new OrderCreated(id, customerId, DateTimeOffset.UtcNow)); // => _events.Add() called
        // => Creation event; app service publishes after persistence
        return order;                                                 // => returns order
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var order = Order.Create(                                             // => order initialised
    new CustomerId("C1"),                                             // => expression
    new List<ProductId> { new("P1"), new("P2") }                      // => expression
); // => order.Id = GUID; order.Events = [OrderCreated]

Key Takeaway: A Factory method encapsulates all construction logic — validation, ID generation, event registration — so callers receive a fully valid, ready aggregate with one method call.

Why It Matters: When aggregate construction logic leaks into application services or controllers, it gets duplicated and diverges. A Factory is the single place where "what makes a valid Order" is enforced at birth. Tests of the Factory verify creation invariants; tests of the aggregate verify behaviour — clear separation of concerns that makes each concern independently testable.


Example 29: Builder pattern for an aggregate

A Builder is appropriate when an aggregate has many optional fields and you want to avoid telescoping constructors. The Builder accumulates settings and the final build() call validates them together, producing a valid aggregate or throwing.

Java:

import java.math.BigDecimal;                                          // => namespace/package import
import java.util.*;                                                   // => namespace/package import
 
// ── Supporting types ──────────────────────────────────────────────────────────
record OrderId(String value)    { static OrderId generate() { return new OrderId(UUID.randomUUID().toString()); } } // => record OrderId
record CustomerId(String value) {}                                    // => record CustomerId
record Money(BigDecimal amount, String currency) {}                   // => record Money
enum Priority { STANDARD, EXPRESS }                                   // => enum Priority
 
// ── Aggregate with optional fields ───────────────────────────────────────────
public class Order {                                                  // => Order field
    private final OrderId   id;                                       // => id field
    private final CustomerId customerId;                              // => customerId field
    private final Money      total;                                   // => total field
    private final Priority   priority;         // => Optional; default STANDARD
    private final String     deliveryNote;     // => Optional; may be null
 
    private Order(OrderId id, CustomerId customerId, Money total,     // => Order method
                  Priority priority, String deliveryNote) {           // => expression
        this.id           = id;                                       // => this.id assigned
        this.customerId   = customerId;                               // => this.customerId assigned
        this.total        = total;                                    // => this.total assigned
        this.priority     = priority;                                 // => this.priority assigned
        this.deliveryNote = deliveryNote;                             // => this.deliveryNote assigned
    }
 
    // ── Inner Builder ─────────────────────────────────────────────────────────
    public static class Builder {                                     // => Builder field
        private CustomerId customerId; // => Required; validated in build()
        private Money      total;      // => Required
        private Priority   priority   = Priority.STANDARD; // => Optional; sensible default
        private String     deliveryNote;                    // => Optional; null allowed
 
        public Builder customerId(CustomerId id)     { this.customerId = id;   return this; } // => customerId method
        public Builder total(Money t)                { this.total = t;         return this; } // => total method
        public Builder priority(Priority p)          { this.priority = p;      return this; } // => priority method
        public Builder deliveryNote(String note)     { this.deliveryNote = note; return this; } // => deliveryNote method
        // => Fluent setters return 'this' for method chaining
 
        public Order build() {                                        // => build method
            // => All validations in one place; no partial Order escapes
            if (customerId == null) throw new IllegalStateException("customerId required"); // => throws if guard fails
            if (total == null)      throw new IllegalStateException("total required"); // => throws if guard fails
            return new Order(OrderId.generate(), customerId, total, priority, deliveryNote); // => returns new Order(OrderId.generate(), 
        }
    }
 
    public static Builder builder() { return new Builder(); } // => Entry point
    public OrderId getId()          { return id; }                    // => getId method
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
Order express = Order.builder()                                       // => Order.builder() called
    .customerId(new CustomerId("C1"))                                 // => expression
    .total(new Money(new BigDecimal("200"), "USD"))                   // => expression
    .priority(Priority.EXPRESS)                                       // => expression
    .deliveryNote("Leave at door")                                    // => expression
    .build();                                                         // => expression
// => express.priority = EXPRESS, deliveryNote = "Leave at door"
 
Order standard = Order.builder()                                      // => Order.builder() called
    .customerId(new CustomerId("C2"))                                 // => expression
    .total(new Money(new BigDecimal("50"), "USD"))                    // => expression
    .build();                                                         // => expression
// => standard.priority = STANDARD, deliveryNote = null (optional not set)

Kotlin:

import java.math.BigDecimal                                           // => namespace/package import
import java.util.UUID                                                 // => namespace/package import
 
data class OrderId(val value: String) { companion object { fun generate() = OrderId(UUID.randomUUID().toString()) } } // => class OrderId
data class CustomerId(val value: String)                              // => class CustomerId
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
enum class Priority { STANDARD, EXPRESS }                             // => enum class
 
// Kotlin: data class with default parameters often replaces Builder
// => But a Builder is still useful when construction validation spans multiple fields
data class Order private constructor(                                 // => class Order
    val id:           OrderId,                                        // => expression
    val customerId:   CustomerId,                                     // => expression
    val total:        Money,                                          // => expression
    val priority:     Priority = Priority.STANDARD,                   // => expression
    val deliveryNote: String?  = null  // => Nullable optional field
) {                                                                   // => expression
    class Builder {                                                   // => class Builder
        private var customerId:   CustomerId? = null                  // => expression
        private var total:        Money?      = null                  // => expression
        private var priority:     Priority    = Priority.STANDARD     // => expression
        private var deliveryNote: String?     = null                  // => expression
 
        fun customerId(id: CustomerId)   = apply { customerId = id }  // => customerId method
        fun total(t: Money)              = apply { total = t }        // => total method
        fun priority(p: Priority)        = apply { priority = p }     // => priority method
        fun deliveryNote(note: String?)  = apply { deliveryNote = note } // => deliveryNote method
        // => apply{} returns 'this'; enables fluent chaining
 
        fun build(): Order {                                          // => build method
            val cid = requireNotNull(customerId) { "customerId required" } // => cid initialised
            val tot = requireNotNull(total)       { "total required" } // => tot initialised
            return Order(OrderId.generate(), cid, tot, priority, deliveryNote) // => returns Order(OrderId.generate(), cid,
            // => Constructs via private constructor; validation done above
        }
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val express = Order.Builder()                                         // => express initialised
    .customerId(CustomerId("C1"))                                     // => expression
    .total(Money(BigDecimal("200"), "USD"))                           // => expression
    .priority(Priority.EXPRESS)                                       // => expression
    .deliveryNote("Leave at door")                                    // => expression
    .build()                                                          // => expression
// => Order(id=UUID, customerId=C1, total=200 USD, priority=EXPRESS, deliveryNote=Leave at door)
 
val standard = Order.Builder()                                        // => standard initialised
    .customerId(CustomerId("C2"))                                     // => expression
    .total(Money(BigDecimal("50"), "USD"))                            // => expression
    .build()                                                          // => expression
// => priority=STANDARD, deliveryNote=null

C#:

using System;                                                         // => namespace/package import
 
public record OrderId(string Value) { public static OrderId Generate() => new(Guid.NewGuid().ToString()); } // => record OrderId
public record CustomerId(string Value);                               // => record CustomerId
public record Money(decimal Amount, string Currency);                 // => record Money
public enum Priority { Standard, Express }                            // => Priority field
 
// C# record with init properties: the record itself acts as a builder target
// For complex validation, a dedicated Builder class keeps the aggregate immutable
public class Order                                                    // => class Order
// => begins block
{
    public OrderId    Id           { get; }                           // => Id field
    public CustomerId CustomerId  { get; }                            // => CustomerId field
    public Money      Total        { get; }                           // => Total field
    public Priority   Priority     { get; }                           // => Priority field
    public string?    DeliveryNote { get; }  // => Nullable; optional
 
    private Order(OrderId id, CustomerId customerId, Money total,     // => Order method
                  Priority priority, string? deliveryNote)            // => expression
    {
        Id = id; CustomerId = customerId; Total = total;              // => Id assigned
        Priority = priority; DeliveryNote = deliveryNote;             // => Priority assigned
    }
 
    // ── Builder ───────────────────────────────────────────────────────────────
    public class Builder                                              // => class Builder
    {
        private CustomerId? _customerId;                              // => expression
        private Money?      _total;                                   // => expression
        private Priority    _priority    = Priority.Standard; // => Default
        private string?     _deliveryNote;                            // => expression
 
        public Builder WithCustomerId(CustomerId id)    { _customerId = id;    return this; } // => WithCustomerId method
        public Builder WithTotal(Money t)               { _total = t;          return this; } // => WithTotal method
        public Builder WithPriority(Priority p)         { _priority = p;       return this; } // => WithPriority method
        public Builder WithDeliveryNote(string? note)   { _deliveryNote = note; return this; } // => WithDeliveryNote method
        // => Fluent; each setter returns 'this'
 
        public Order Build()                                          // => Build method
        {
            if (_customerId is null) throw new InvalidOperationException("CustomerId required"); // => throws if guard fails
            if (_total      is null) throw new InvalidOperationException("Total required"); // => throws if guard fails
            return new Order(OrderId.Generate(), _customerId, _total, _priority, _deliveryNote); // => returns new Order(OrderId.Generate(), 
        }
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var express = new Order.Builder()                                     // => express initialised
    .WithCustomerId(new CustomerId("C1"))                             // => expression
    .WithTotal(new Money(200m, "USD"))                                // => expression
    .WithPriority(Priority.Express)                                   // => expression
    .WithDeliveryNote("Leave at door")                                // => expression
    .Build();                                                         // => expression
// => express.Priority = Express, DeliveryNote = "Leave at door"
 
var standard = new Order.Builder()                                    // => standard initialised
    .WithCustomerId(new CustomerId("C2"))                             // => expression
    .WithTotal(new Money(50m, "USD"))                                 // => expression
    .Build();                                                         // => expression
// => standard.Priority = Standard, DeliveryNote = null

Key Takeaway: A Builder accumulates optional fields and performs cross-field validation at build() time, ensuring every Order produced is fully valid while avoiding a constructor with many nullable parameters.

Why It Matters: Telescoping constructors — new Order(id, cid, total, null, null, null, "EXPRESS") — are error-prone and unreadable. A Builder gives each field a name at the call site, makes optionality explicit, and centralises cross-field validation. In Java and C# the pattern is explicit class; in Kotlin, named parameters with default values often suffice, but a Builder still clarifies construction intent for aggregates with domain invariants spanning multiple fields.


Repository Conventions (Examples 30-31)

Example 30: Repository query convention — finder methods

Repository finder methods express domain queries in the language of the domain rather than exposing raw SQL or ORM predicates. findByCustomerId reads like a business question; createQuery("SELECT ...") does not.

Java:

import java.util.*;                                                   // => namespace/package import
 
// ── Supporting types ──────────────────────────────────────────────────────────
record OrderId(String value) {}                                       // => record OrderId
record CustomerId(String value) {}                                    // => record CustomerId
enum OrderStatus { PENDING, CONFIRMED, SHIPPED }                      // => enum OrderStatus
record Order(OrderId id, CustomerId customerId, OrderStatus status) {} // => record Order
 
// ── Repository interface: domain-language queries ─────────────────────────────
interface OrderRepository {                                           // => interface OrderRepository
    // Finder methods named from the domain perspective, not the database perspective
    Optional<Order> findById(OrderId id);                             // => expression
    // => "find" prefix: may or may not exist — returns Optional
 
    List<Order> findByCustomerId(CustomerId customerId);              // => expression
    // => "findBy": returns all orders for a customer; empty list if none
 
    List<Order> findByStatus(OrderStatus status);                     // => expression
    // => Returns all orders in a given status; useful for fulfilment workflows
 
    List<Order> findPendingOlderThanDays(int days);                   // => expression
    // => Named domain query: "which pending orders are stale?"
 
    void save(Order order);   // => Insert or update (upsert semantics)
    void delete(OrderId id);  // => Remove from repository
}
 
// ── In-memory implementation (for tests / demos) ──────────────────────────────
class InMemoryOrderRepository implements OrderRepository {            // => class InMemoryOrderRepository
    private final Map<String, Order> store = new HashMap<>();         // => Map method
    // => Keyed by OrderId value for O(1) lookup
 
    @Override public Optional<Order> findById(OrderId id) {           // => expression
        return Optional.ofNullable(store.get(id.value())); // => Absent = Optional.empty
    }
 
    @Override public List<Order> findByCustomerId(CustomerId cid) {   // => expression
        return store.values().stream()                                // => returns store.values().stream()
            .filter(o -> o.customerId().equals(cid)) // => Linear scan; acceptable in-memory
            .toList();                                                // => expression
    }
 
    @Override public List<Order> findByStatus(OrderStatus s) {        // => expression
        return store.values().stream().filter(o -> o.status() == s).toList(); // => returns store.values().stream().filter
    }
 
    @Override public List<Order> findPendingOlderThanDays(int days) { // => expression
        // => Real impl would compare timestamps; simplified here
        return store.values().stream()                                // => returns store.values().stream()
            .filter(o -> o.status() == OrderStatus.PENDING).toList(); // => o.status() called
    }
 
    @Override public void save(Order o)   { store.put(o.id().value(), o); } // => store.put() called
    @Override public void delete(OrderId id) { store.remove(id.value()); } // => store.remove() called
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var repo  = new InMemoryOrderRepository();                            // => repo initialised
var order = new Order(new OrderId("O1"), new CustomerId("C1"), OrderStatus.PENDING); // => order initialised
repo.save(order);  // => Stored
 
Optional<Order> found = repo.findById(new OrderId("O1")); // => Optional[Order]
List<Order>     byCustomer = repo.findByCustomerId(new CustomerId("C1")); // => [order]
List<Order>     pending    = repo.findByStatus(OrderStatus.PENDING);      // => [order]

Kotlin:

// ── Kotlin repository interface ───────────────────────────────────────────────
data class OrderId(val value: String)                                 // => class OrderId
data class CustomerId(val value: String)                              // => class CustomerId
enum class OrderStatus { PENDING, CONFIRMED, SHIPPED }                // => enum class
data class Order(val id: OrderId, val customerId: CustomerId, val status: OrderStatus) // => class Order
 
interface OrderRepository {                                           // => interface OrderRepository
    fun findById(id: OrderId): Order?             // => Kotlin nullable: null = not found
    fun findByCustomerId(cid: CustomerId): List<Order>                // => findByCustomerId method
    fun findByStatus(status: OrderStatus): List<Order>                // => findByStatus method
    fun findPendingOlderThanDays(days: Int): List<Order>              // => findPendingOlderThanDays method
    fun save(order: Order)                                            // => save method
    fun delete(id: OrderId)                                           // => delete method
}
 
// ── In-memory implementation ──────────────────────────────────────────────────
class InMemoryOrderRepository : OrderRepository {                     // => class InMemoryOrderRepository
    private val store = mutableMapOf<String, Order>()                 // => store declared
 
    override fun findById(id: OrderId)           = store[id.value]    // => findById method
    // => Nullable return: map returns null when key absent
 
    override fun findByCustomerId(cid: CustomerId) =                  // => findByCustomerId method
        store.values.filter { it.customerId == cid } // => Returns empty list if none
 
    override fun findByStatus(status: OrderStatus) =                  // => findByStatus method
        store.values.filter { it.status == status }                   // => expression
 
    override fun findPendingOlderThanDays(days: Int) =                // => findPendingOlderThanDays method
        store.values.filter { it.status == OrderStatus.PENDING }      // => expression
    // => Simplified; real impl would use timestamps
 
    override fun save(order: Order)  { store[order.id.value] = order } // => save method
    override fun delete(id: OrderId) { store.remove(id.value) }       // => delete method
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val repo  = InMemoryOrderRepository()                                 // => repo initialised
val order = Order(OrderId("O1"), CustomerId("C1"), OrderStatus.PENDING) // => order initialised
repo.save(order)                                                      // => repo.save() called
 
val found      = repo.findById(OrderId("O1"))          // => Order? (non-null here)
val byCustomer = repo.findByCustomerId(CustomerId("C1")) // => [order]
val pending    = repo.findByStatus(OrderStatus.PENDING)  // => [order]

C#:

using System.Collections.Generic;  // => Dictionary, List, IEnumerable
 
public record OrderId(string Value);                                  // => record OrderId
// => Typed id; wraps string; prevents mixing OrderId with CustomerId
public record CustomerId(string Value);                               // => record CustomerId
// => Typed customer id; structural equality via record
public enum OrderStatus { Pending, Confirmed, Shipped }               // => OrderStatus field
// => Three lifecycle states; interface methods filter by each state
public record Order(OrderId Id, CustomerId CustomerId, OrderStatus Status); // => record Order
// => Immutable order record; three fields capturing identity, owner, and lifecycle
 
public interface IOrderRepository                                     // => interface IOrderRepository
// => Domain-language interface; declares what domain needs from persistence
{
    Order?        FindById(OrderId id);              // => Nullable: null = not found
    // => Nullable return: caller must handle "not found" case explicitly (no exception)
    List<Order>   FindByCustomerId(CustomerId id);                    // => expression
    // => Returns all orders for this customer; empty list if none
    List<Order>   FindByStatus(OrderStatus status);                   // => expression
    // => Returns all orders in this status; production uses DB index on status column
    List<Order>   FindPendingOlderThanDays(int days);                 // => expression
    // => Business query: "stale" pending orders need follow-up; expressed in domain vocabulary
    void          Save(Order order);                                  // => expression
    // => Upsert: inserts new id or replaces existing id
    void          Delete(OrderId id);                                 // => expression
    // => Removes order; no-op if id not found (idempotent)
}
 
public class InMemoryOrderRepository : IOrderRepository               // => class InMemoryOrderRepository
// => In-memory implementation for tests and demos; not for production
{
    private readonly Dictionary<string, Order> _store = new();        // => Dictionary method
    // => Key = order id string; O(1) FindById lookups
 
    public Order? FindById(OrderId id) =>                             // => method declaration
        _store.TryGetValue(id.Value, out var o) ? o : null;           // => _store.TryGetValue() called
    // => TryGetValue avoids KeyNotFoundException; returns null when absent
    // => Ternary: returns o if found, null otherwise
 
    public List<Order> FindByCustomerId(CustomerId id) =>             // => FindByCustomerId method
        _store.Values.Where(o => o.CustomerId == id).ToList();        // => Values.Where() called
    // => LINQ Where: filters in-memory; production uses DB WHERE clause
 
    public List<Order> FindByStatus(OrderStatus s) =>                 // => FindByStatus method
        _store.Values.Where(o => o.Status == s).ToList();             // => Values.Where() called
    // => Filters by status enum; record == compares Status value
 
    public List<Order> FindPendingOlderThanDays(int days) =>          // => FindPendingOlderThanDays method
        _store.Values.Where(o => o.Status == OrderStatus.Pending).ToList(); // => Values.Where() called
    // => Simplified: ignores 'days' parameter; real impl filters by creation date
 
    public void Save(Order o)      => _store[o.Id.Value] = o;         // => Save method
    // => Indexer assignment: inserts or replaces; upsert semantics
    public void Delete(OrderId id) => _store.Remove(id.Value);        // => Delete method
    // => Remove: no-op if key absent; no exception on missing id
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var repo  = new InMemoryOrderRepository();                            // => repo initialised
// => Empty store; no orders yet
var order = new Order(new OrderId("O1"), new CustomerId("C1"), OrderStatus.Pending); // => order initialised
// => Immutable record: O1, C1, Pending
repo.Save(order);                                                     // => repo.Save() called
// => _store = {"O1": order}
 
var found   = repo.FindById(new OrderId("O1"));             // => Order (non-null)
// => TryGetValue finds "O1"; returns the stored Order
var pending = repo.FindByStatus(OrderStatus.Pending);        // => [order]
// => Where filter matches; one order in result list

Key Takeaway: Repository finder methods express queries in domain vocabulary. findPendingOlderThanDays(7) communicates business intent; a raw query does not.

Why It Matters: When domain queries are expressed in business language inside the Repository interface, domain experts can read the interface and confirm it covers their use cases. Infrastructure implementation details — SQL, JPA, EF Core — are hidden behind the interface. This decoupling lets you swap from in-memory (tests) to PostgreSQL (production) without changing any domain or application code.


Example 31: Repository with Specification parameter

Passing a Specification to a repository's findAll(spec) method eliminates the need for a new finder method per query combination. The repository evaluates the spec against its data source, keeping query logic composable and the repository interface stable.

Java:

import java.math.BigDecimal;                                          // => namespace/package import
import java.util.*;                                                   // => namespace/package import
 
enum OrderStatus { PENDING, CONFIRMED }                               // => enum OrderStatus
record OrderId(String value) {}                                       // => record OrderId
record Money(BigDecimal amount, String currency) {}                   // => record Money
record Order(OrderId id, Money total, OrderStatus status) {}          // => record Order
 
interface Specification<T> { boolean isSatisfiedBy(T t); }            // => interface Specification
 
// ── Repository accepting a Specification ─────────────────────────────────────
interface OrderRepository {                                           // => interface OrderRepository
    List<Order> findAll(Specification<Order> spec);                   // => expression
    // => One method covers all possible filtering combinations
    void save(Order order);                                           // => expression
}
 
class InMemoryOrderRepository implements OrderRepository {            // => class InMemoryOrderRepository
    private final Map<String, Order> store = new HashMap<>();         // => Map method
 
    @Override                                                         // => expression
    public List<Order> findAll(Specification<Order> spec) {           // => findAll method
        return store.values().stream()                                // => returns store.values().stream()
            .filter(spec::isSatisfiedBy) // => Delegate filtering to the spec
            .toList();                                                // => expression
        // => In-memory: linear scan; SQL impl would translate spec to WHERE clause
    }
 
    @Override public void save(Order o) { store.put(o.id().value(), o); } // => store.put() called
}
 
// ── Specs ─────────────────────────────────────────────────────────────────────
Specification<Order> bigOrder  = o -> o.total().amount().compareTo(new BigDecimal("100")) >= 0; // => o.total() called
Specification<Order> confirmed = o -> o.status() == OrderStatus.CONFIRMED; // => o.status() called
Specification<Order> combined  = t -> bigOrder.isSatisfiedBy(t) && confirmed.isSatisfiedBy(t); // => bigOrder.isSatisfiedBy() called
// => No new repository method needed; combine specs at call site
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var repo = new InMemoryOrderRepository();                             // => repo initialised
repo.save(new Order(new OrderId("O1"), new Money(new BigDecimal("120"), "USD"), OrderStatus.CONFIRMED)); // => repo.save() called
repo.save(new Order(new OrderId("O2"), new Money(new BigDecimal("50"),  "USD"), OrderStatus.CONFIRMED)); // => repo.save() called
repo.save(new Order(new OrderId("O3"), new Money(new BigDecimal("120"), "USD"), OrderStatus.PENDING)); // => repo.save() called
 
List<Order> results = repo.findAll(combined);                         // => repo.findAll() called
// => [Order O1] — only O1 satisfies both bigOrder and confirmed

Kotlin:

import java.math.BigDecimal                                           // => namespace/package import
 
enum class OrderStatus { PENDING, CONFIRMED }                         // => enum class
// => Two states; Specification will filter by CONFIRMED
data class OrderId(val value: String)                                 // => class OrderId
// => Typed identity; value class would give zero overhead in production
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
// => amount + currency together; prevents unit-less arithmetic
data class Order(val id: OrderId, val total: Money, val status: OrderStatus) // => class Order
// => Plain data; no domain logic here — used to demonstrate repo filtering
 
fun interface Specification<T> { fun isSatisfiedBy(t: T): Boolean }   // => interface Specification
// => fun interface: SAM type; enables lambda syntax at call site
 
interface OrderRepository {                                           // => interface OrderRepository
    fun findAll(spec: Specification<Order>): List<Order>              // => findAll method
    // => One method replaces: findByStatus, findByTotalAbove, findByStatusAndTotal, etc.
    fun save(order: Order)                                            // => save method
    // => Upsert semantics; new id = insert, existing id = update
}
 
class InMemoryOrderRepository : OrderRepository {                     // => class InMemoryOrderRepository
    private val store = mutableMapOf<String, Order>()                 // => store declared
    // => Key = order id string; value = Order aggregate
 
    override fun findAll(spec: Specification<Order>) =                // => findAll method
        store.values.filter(spec::isSatisfiedBy)                      // => values.filter() called
    // => Delegates filtering to spec; repository stays generic
    // => SQL impl would translate spec to a WHERE clause or JPA Criteria
 
    override fun save(order: Order) { store[order.id.value] = order } // => save method
    // => Overwrites if id already present; no explicit insert/update distinction
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val repo = InMemoryOrderRepository()                                  // => repo initialised
// => Empty store; three orders added below
repo.save(Order(OrderId("O1"), Money(BigDecimal("120"), "USD"), OrderStatus.CONFIRMED)) // => repo.save() called
// => store = {O1: Order(total=120 USD, status=CONFIRMED)}
repo.save(Order(OrderId("O2"), Money(BigDecimal("50"),  "USD"), OrderStatus.CONFIRMED)) // => repo.save() called
// => store = {O1: ..., O2: Order(total=50 USD, status=CONFIRMED)}
repo.save(Order(OrderId("O3"), Money(BigDecimal("120"), "USD"), OrderStatus.PENDING)) // => repo.save() called
// => store = {O1, O2, O3}; O3 is PENDING so will not match bigConfirmed
 
val bigConfirmed = Specification<Order> {                             // => bigConfirmed initialised
    it.total.amount >= BigDecimal("100") && it.status == OrderStatus.CONFIRMED // => expression
    // => Lambda evaluated for each order: O1 passes (120>=100 AND CONFIRMED); O2 fails (50<100)
}
val results = repo.findAll(bigConfirmed) // => [Order O1]
// => O2 fails amount check; O3 fails status check; only O1 satisfies both conditions

C#:

using System.Collections.Generic;                                     // => namespace/package import
using System.Linq;                                                    // => namespace/package import
 
public enum OrderStatus { Pending, Confirmed }                        // => OrderStatus field
// => Two states; specification will filter by Confirmed
public record OrderId(string Value);                                  // => record OrderId
// => Typed identity; prevents string mix-ups at call sites
public record Money(decimal Amount, string Currency);                 // => record Money
// => Value Object; amount + currency always travel together
public record Order(OrderId Id, Money Total, OrderStatus Status);     // => record Order
// => Simple immutable order record for demonstration
 
public interface ISpecification<T> { bool IsSatisfiedBy(T t); }       // => interface ISpecification
// => Single method: returns true when t satisfies the encapsulated rule
 
public interface IOrderRepository                                     // => interface IOrderRepository
{
    List<Order> FindAll(ISpecification<Order> spec);                  // => expression
    // => One method handles all filter combinations; no new methods per query
    void Save(Order order);                                           // => expression
    // => Upsert: insert on new id, update on existing id
}
 
public class InMemoryOrderRepository : IOrderRepository               // => class InMemoryOrderRepository
{
    private readonly Dictionary<string, Order> _store = new();        // => Dictionary method
    // => String key = order id value; production uses DB row ids
 
    public List<Order> FindAll(ISpecification<Order> spec) =>         // => FindAll method
        _store.Values.Where(spec.IsSatisfiedBy).ToList();             // => Values.Where() called
    // => Evaluates spec against every order in the store
    // => SQL impl would translate spec to WHERE clause via EF Core expressions
 
    public void Save(Order o) => _store[o.Id.Value] = o;              // => Save method
    // => Overwrites if id already in dictionary; no explicit insert/update split
}
 
public class LambdaSpec<T>(System.Func<T, bool> predicate) : ISpecification<T> // => class LambdaSpec
{
    // => Primary constructor (C# 12): predicate injected and stored automatically
    public bool IsSatisfiedBy(T t) => predicate(t);                   // => IsSatisfiedBy method
    // => Delegates to the stored lambda; one indirection over a raw Func<T,bool>
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var repo = new InMemoryOrderRepository();                             // => repo initialised
// => Empty store; three orders added below
repo.Save(new Order(new OrderId("O1"), new Money(120m, "USD"), OrderStatus.Confirmed)); // => repo.Save() called
// => _store = {O1: Order(Total=120 USD, Status=Confirmed)}
repo.Save(new Order(new OrderId("O2"), new Money(50m,  "USD"), OrderStatus.Confirmed)); // => repo.Save() called
// => _store = {O1, O2: 50 USD Confirmed}; O2 will fail amount check
repo.Save(new Order(new OrderId("O3"), new Money(120m, "USD"), OrderStatus.Pending)); // => repo.Save() called
// => _store = {O1, O2, O3}; O3 is Pending so will fail status check
 
var bigConfirmed = new LambdaSpec<Order>(o => o.Total.Amount >= 100m && o.Status == OrderStatus.Confirmed); // => bigConfirmed initialised
// => Lambda: O1 passes (120>=100 AND Confirmed); O2 fails (50<100); O3 fails (Pending)
var results = repo.FindAll(bigConfirmed); // => [Order O1]
// => Only O1 satisfies both conditions; list has one element

Key Takeaway: A repository that accepts a Specification parameter stays stable as query combinations grow — no new finder method per query flavour.

Why It Matters: Without Specification parameters, a busy domain accumulates dozens of findByStatusAndTotalGreaterThan, findByCustomerAndStatus, findExpiredSince methods. The repository interface becomes a growing list of narrow queries. Passing a Specification collapses this explosion to one findAll(spec) method. In production, the Specification-to-query translation layer (e.g., JPA Criteria API, EF Core expressions) is the only place that touches the database API.


Aggregate Integration (Examples 32-35)

Example 32: Aggregate references another aggregate by ID, not object

When one aggregate needs to refer to another, it stores the other aggregate's ID — not a direct object reference. This preserves aggregate boundaries: loading Order does not automatically load the entire Customer object graph.

Java:

import java.math.BigDecimal;                                          // => namespace/package import
 
// ── Two aggregate roots, each behind its own repository ───────────────────────
record CustomerId(String value) {}                                    // => record CustomerId
record OrderId(String value)    {}                                    // => record OrderId
record Money(BigDecimal amount, String currency) {}                   // => record Money
 
// Customer aggregate root
class Customer {                                                      // => class Customer
    private final CustomerId id;                                      // => id field
    private final String name;                                        // => name field
    // => Customer has its own lifecycle; Order must not hold a Customer reference
 
    Customer(CustomerId id, String name) { this.id = id; this.name = name; } // => Customer() called
    public CustomerId getId() { return id; }                          // => getId method
    public String getName()   { return name; }                        // => getName method
}
 
// Order aggregate root — stores CustomerId, NOT Customer
class Order {                                                         // => class Order
    private final OrderId    id;                                      // => id field
    private final CustomerId customerId; // => ID only; no Customer object here
    private final Money      total;                                   // => total field
    // => If Order held a Customer reference, loading Order would load Customer data,
    //    violating aggregate boundaries and creating hidden coupling
 
    Order(OrderId id, CustomerId customerId, Money total) {           // => Order() called
        this.id = id; this.customerId = customerId; this.total = total; // => this.id assigned
    }
 
    public CustomerId getCustomerId() { return customerId; }          // => getCustomerId method
    // => Caller queries CustomerRepository if Customer data is needed
}
 
// ── Usage: application service loads each aggregate separately ────────────────
// Pseudocode showing the separation principle:
// Customer customer = customerRepo.findById(order.getCustomerId()).orElseThrow();
// => Two separate repository calls; two transaction scopes possible
// => Each aggregate loaded only when its data is actually needed

Kotlin:

import java.math.BigDecimal                                           // => namespace/package import
 
data class CustomerId(val value: String)                              // => class CustomerId
data class OrderId(val value: String)                                 // => class OrderId
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
 
data class Customer(val id: CustomerId, val name: String)             // => class Customer
// => Customer is its own aggregate root with its own repository
 
data class Order(                                                     // => class Order
    val id:         OrderId,                                          // => expression
    val customerId: CustomerId, // => ID reference only — no Customer object
    val total:      Money                                             // => expression
)
// => Order aggregate does not reach into the Customer aggregate
// => "Cross-aggregate reference by ID" is the DDD rule
 
// ── Application service loads both when needed ────────────────────────────────
// val order    = orderRepo.findById(orderId) ?: error("not found")
// val customer = customerRepo.findById(order.customerId) ?: error("not found")
// => Each repository call is independent; lazy loading by design

C#:

public record CustomerId(string Value);                               // => record CustomerId
public record OrderId(string Value);                                  // => record OrderId
public record Money(decimal Amount, string Currency);                 // => record Money
 
public class Customer                                                 // => class Customer
{
    public CustomerId Id   { get; }                                   // => Id field
    public string     Name { get; }                                   // => Name field
    public Customer(CustomerId id, string name) { Id = id; Name = name; } // => Customer method
}
 
public class Order                                                    // => class Order
{
    public OrderId    Id         { get; }                             // => Id field
    public CustomerId CustomerId { get; } // => ID only; no Customer navigation property
    public Money      Total      { get; }                             // => Total field
    // => Entity Framework "navigation properties" to other aggregate roots violate boundaries
    // => DDD rule: only store the foreign aggregate's ID in this aggregate
 
    public Order(OrderId id, CustomerId customerId, Money total)      // => Order method
    {
        Id = id; CustomerId = customerId; Total = total;              // => Id assigned
    }
}
 
// ── Application service pattern ────────────────────────────────────────────────
// var order    = orderRepo.FindById(orderId) ?? throw new KeyNotFoundException();
// var customer = customerRepo.FindById(order.CustomerId) ?? throw new KeyNotFoundException();
// => Explicit two-step load; no automatic join or lazy load across aggregate boundary

Key Takeaway: Store the ID of a foreign aggregate, never the aggregate object itself. Loading happens through the other aggregate's repository, on demand, in explicit code.

Why It Matters: Direct object references between aggregates create implicit coupling: serialising Order accidentally serialises the whole Customer graph. More critically, it blurs transaction boundaries — should saving Order also save Customer? This question has no good answer when they are directly coupled. By reference-by-ID, each aggregate has exactly one repository and one transaction scope, which is what makes aggregates independently deployable as microservices without shared database transactions.


Example 33: Eventual consistency between aggregates

When two aggregates must stay consistent but cannot share a database transaction, eventual consistency via Domain Events is the DDD solution. Order publishes OrderConfirmed; Inventory handles it asynchronously and updates stock.

sequenceDiagram
    participant OS as OrderService
    participant OR as OrderRepository
    participant EP as EventPublisher
    participant IH as InventoryHandler
    participant IR as InventoryRepository
 
    OS->>OR: save(order)
    OR-->>OS: saved
    OS->>EP: publish(OrderConfirmed)
    EP->>IH: handle(OrderConfirmed)
    IH->>IR: decreaseStock(productId, qty)

Java:

import java.time.Instant;                                             // => namespace/package import
import java.util.*;                                                   // => namespace/package import
// => java.util.* provides List, Map, HashMap used throughout
 
// ── Domain events ─────────────────────────────────────────────────────────────
// Events carry the data needed by downstream handlers; nothing more, nothing less
interface DomainEvent {}                                              // => interface DomainEvent
// => Marker interface: all domain events implement this for dispatcher registration
record ProductId(String value) {}                                     // => record ProductId
// => Typed product identity; prevents mixing with OrderId or CustomerId
record OrderId(String value)   {}                                     // => record OrderId
// => Typed order identity; self-documenting in method signatures
 
record OrderConfirmed(                                                // => record OrderConfirmed
    OrderId orderId,     // => Which order was confirmed
    List<ProductId> items, // => Which products need stock decremented
    Instant occurredAt   // => When the confirmation happened; useful for ordering / audit
) implements DomainEvent {}                                           // => expression
// => Immutable event record: past tense name signals it is a historical fact
 
// ── Inventory aggregate — independent lifecycle ───────────────────────────────
// Inventory is a separate aggregate root; Order knows nothing about Inventory internals
// => "Separate aggregate root" means separate repository, separate transaction scope
class Inventory {                                                     // => class Inventory
    private final Map<String, Integer> stock = new HashMap<>();       // => Map method
    // => productId value (String) maps to current stock quantity (Integer)
    // => Inventory is its own aggregate root with its own repository in production
 
    public void initialiseStock(ProductId productId, int qty) {       // => initialiseStock method
        stock.put(productId.value(), qty);                            // => stock.put() called
        // => Seed stock for a product; in production called when product is added to catalogue
    }
 
    public void decreaseStock(ProductId productId, int qty) {         // => decreaseStock method
        int current = stock.getOrDefault(productId.value(), 0);       // => stock.getOrDefault() called
        // => getOrDefault: treats unknown products as having zero stock
        if (current < qty)                                            // => precondition check
            throw new IllegalStateException("Insufficient stock: " + productId); // => throws if guard fails
            // => Inventory enforces its own invariant: stock cannot go negative
            // => Handler catches this and sends the event to a dead-letter queue
        stock.put(productId.value(), current - qty);                  // => stock.put() called
        // => Atomic update: current quantity decremented by requested qty
    }
 
    public int getStock(ProductId pid) {                              // => getStock method
        return stock.getOrDefault(pid.value(), 0);                    // => returns stock.getOrDefault(pid.value()
        // => Returns 0 for unknown products rather than null
    }
}
 
// ── Domain event handler: bridges Order domain and Inventory domain ───────────
// This handler runs in a SEPARATE transaction from the one that saved Order
// => Eventual consistency: Inventory may lag Order by milliseconds in production
class OrderConfirmedHandler {                                         // => class OrderConfirmedHandler
    private final Inventory inventory;                                // => inventory field
    // => Inventory injected; handler has no direct dependency on Order aggregate
 
    OrderConfirmedHandler(Inventory inventory) { this.inventory = inventory; } // => OrderConfirmedHandler() called
 
    public void handle(OrderConfirmed event) {                        // => handle method
        // => event carries all data needed; no need to reload Order from repository
        for (ProductId item : event.items()) {                        // => iteration over collection
            inventory.decreaseStock(item, 1);                         // => inventory.decreaseStock() called
            // => Each item in the confirmed order decreases stock by 1 unit
            // => In production: wrapped in retry logic; message queue handles transient failures
        }
        // => Handler is idempotent if decreaseStock checks for exact quantity match
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var inventory = new Inventory();                                      // => inventory initialised
inventory.initialiseStock(new ProductId("P1"), 10);                   // => inventory.initialiseStock() called
// => P1 starts with 10 units in stock
 
var handler = new OrderConfirmedHandler(inventory);                   // => handler initialised
var event   = new OrderConfirmed(                                     // => event initialised
    new OrderId("O1"),                                                // => expression
    List.of(new ProductId("P1"), new ProductId("P1")),                // => List.of() called
    // => Two line items referencing P1: reduces stock by 2 total
    Instant.now()                                                     // => Instant.now() called
    // => Timestamp records when the order was confirmed; useful for audit and replay
);                                                                    // => expression
 
handler.handle(event);                                                // => handler.handle() called
// => handler processes both P1 items; decreaseStock called twice
int remaining = inventory.getStock(new ProductId("P1"));              // => inventory.getStock() called
// => 8 (10 initial - 2 from the two confirmed P1 items)

Kotlin:

import java.time.Instant                                              // => namespace/package import
 
interface DomainEvent                                                 // => interface DomainEvent
data class ProductId(val value: String)                               // => class ProductId
data class OrderId(val value: String)                                 // => class OrderId
data class OrderConfirmed(val orderId: OrderId, val items: List<ProductId>, val occurredAt: Instant) : DomainEvent // => class OrderConfirmed
 
class Inventory {                                                     // => class Inventory
    private val stock = mutableMapOf<String, Int>()                   // => stock declared
 
    fun initialiseStock(productId: ProductId, qty: Int) { stock[productId.value] = qty } // => initialiseStock method
 
    fun decreaseStock(productId: ProductId, qty: Int) {               // => decreaseStock method
        val current = stock[productId.value] ?: 0                     // => current initialised
        check(current >= qty) { "Insufficient stock: ${productId.value}" } // => precondition check
        stock[productId.value] = current - qty                        // => expression
        // => Invariant: stock >= 0 enforced here, not in the handler
    }
 
    fun getStock(productId: ProductId) = stock[productId.value] ?: 0  // => getStock method
}
 
class OrderConfirmedHandler(private val inventory: Inventory) {       // => class OrderConfirmedHandler
    fun handle(event: OrderConfirmed) {                               // => handle method
        event.items.forEach { inventory.decreaseStock(it, 1) }        // => inventory.decreaseStock() called
        // => Each item in the event decreases stock by 1
        // => Handler runs in its own transaction; eventual consistency achieved
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val inventory = Inventory()                                           // => inventory initialised
inventory.initialiseStock(ProductId("P1"), 10) // => P1 stock = 10
 
val event = OrderConfirmed(OrderId("O1"), listOf(ProductId("P1"), ProductId("P1")), Instant.now()) // => event initialised
OrderConfirmedHandler(inventory).handle(event)                        // => OrderConfirmedHandler() called
 
val remaining = inventory.getStock(ProductId("P1")) // => 8

C#:

using System;                                                         // => namespace/package import
using System.Collections.Generic;                                     // => namespace/package import
 
public interface IDomainEvent {}                                      // => IDomainEvent field
public record ProductId(string Value);                                // => record ProductId
public record OrderId(string Value);                                  // => record OrderId
public record OrderConfirmed(OrderId OrderId, List<ProductId> Items, DateTimeOffset OccurredAt) : IDomainEvent; // => record OrderConfirmed
 
public class Inventory                                                // => class Inventory
// => begins block
{
    private readonly Dictionary<string, int> _stock = new();          // => Dictionary method
 
    public void InitialiseStock(ProductId productId, int qty) => _stock[productId.Value] = qty; // => InitialiseStock method
 
    public void DecreaseStock(ProductId productId, int qty)           // => DecreaseStock method
    // => begins block
    {
        var current = _stock.GetValueOrDefault(productId.Value, 0);   // => current initialised
        if (current < qty) throw new InvalidOperationException($"Insufficient stock: {productId.Value}"); // => throws if guard fails
        _stock[productId.Value] = current - qty; // => Stock updated; invariant protected
    // => ends block
    }
 
    public int GetStock(ProductId pid) => _stock.GetValueOrDefault(pid.Value, 0); // => GetStock method
// => ends block
}
 
public class OrderConfirmedHandler(Inventory inventory)               // => class OrderConfirmedHandler
// => begins block
{
    public void Handle(OrderConfirmed evt)                            // => Handle method
    // => begins block
    {
        foreach (var item in evt.Items)                               // => iteration over collection
            inventory.DecreaseStock(item, 1); // => Eventual consistency: runs after Order transaction
    // => ends block
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var inv = new Inventory();                                            // => inv initialised
inv.InitialiseStock(new ProductId("P1"), 10); // => P1 = 10
 
var evt = new OrderConfirmed(new OrderId("O1"),                       // => evt initialised
    new List<ProductId> { new("P1"), new("P1") }, DateTimeOffset.UtcNow); // => expression
new OrderConfirmedHandler(inv).Handle(evt);                           // => expression
 
int remaining = inv.GetStock(new ProductId("P1")); // => 8

Key Takeaway: Eventual consistency via Domain Events lets two aggregates stay in sync across separate transactions. The publishing aggregate does not need to know which downstream systems react.

Why It Matters: Forcing two aggregates into a single transaction defeats their independence. A distributed system cannot guarantee a two-phase commit. Domain Events through a message broker (Kafka, RabbitMQ) provide the decoupling: Order commits, publishes OrderConfirmed, and Inventory reacts in a separate transaction. Idempotent handlers and retry queues handle failures — a resilience pattern not available with synchronous two-aggregate transactions.


Example 34: Domain event handler

A Domain Event Handler is a class whose sole purpose is reacting to one event type. It is registered with an event dispatcher and invoked when that event is published. Separating the handler from the aggregate keeps reaction logic outside the aggregate.

Java:

import java.time.Instant;                                             // => namespace/package import
import java.util.*;                                                   // => namespace/package import
import java.util.function.Consumer;                                   // => namespace/package import
 
// ── Infrastructure: simple synchronous event dispatcher ───────────────────────
interface DomainEvent {}                                              // => interface DomainEvent
 
class EventDispatcher {                                               // => class EventDispatcher
    private final Map<Class<?>, List<Consumer<DomainEvent>>> handlers = new HashMap<>(); // => Map method
    // => Maps event type to list of handlers; multiple handlers per event supported
 
    @SuppressWarnings("unchecked")                                    // => expression
    public <T extends DomainEvent> void register(Class<T> eventType, Consumer<T> handler) { // => expression
        handlers.computeIfAbsent(eventType, k -> new ArrayList<>())   // => handlers.computeIfAbsent() called
                .add(e -> handler.accept((T) e)); // => Type-safe cast at registration
    }
 
    public void publish(DomainEvent event) {                          // => publish method
        var eventHandlers = handlers.getOrDefault(event.getClass(), List.of()); // => eventHandlers initialised
        eventHandlers.forEach(h -> h.accept(event)); // => Invoke each registered handler
    }
}
 
// ── Events ────────────────────────────────────────────────────────────────────
record OrderId(String value) {}                                       // => record OrderId
record CustomerId(String value) {}                                    // => record CustomerId
record OrderPlaced(OrderId orderId, CustomerId customerId, Instant occurredAt) implements DomainEvent {} // => record OrderPlaced
 
// ── Handler classes: one responsibility each ─────────────────────────────────
class SendConfirmationEmailHandler {                                  // => class SendConfirmationEmailHandler
    // => Reacts to OrderPlaced; sends customer confirmation
    public void handle(OrderPlaced event) {                           // => handle method
        System.out.println("Sending confirmation email for order " + event.orderId().value()); // => output to console
        // => In production: inject EmailService; call sendEmail(event.customerId())
    }
}
 
class UpdateLoyaltyPointsHandler {                                    // => class UpdateLoyaltyPointsHandler
    // => Reacts to OrderPlaced; credits loyalty points
    public void handle(OrderPlaced event) {                           // => handle method
        System.out.println("Adding loyalty points for customer " + event.customerId().value()); // => output to console
        // => In production: inject LoyaltyService; call addPoints(event.customerId())
    }
}
 
// ── Wire up: registration at application startup ──────────────────────────────
var dispatcher     = new EventDispatcher();                           // => dispatcher initialised
var emailHandler   = new SendConfirmationEmailHandler();              // => emailHandler initialised
var loyaltyHandler = new UpdateLoyaltyPointsHandler();                // => loyaltyHandler initialised
 
dispatcher.register(OrderPlaced.class, emailHandler::handle);         // => dispatcher.register() called
// => When OrderPlaced fires, call emailHandler.handle(...)
dispatcher.register(OrderPlaced.class, loyaltyHandler::handle);       // => dispatcher.register() called
// => Both handlers registered; both invoked when event published
 
// ── Trigger ───────────────────────────────────────────────────────────────────
var event = new OrderPlaced(new OrderId("O1"), new CustomerId("C1"), Instant.now()); // => event initialised
dispatcher.publish(event);                                            // => dispatcher.publish() called
// => Output: Sending confirmation email for order O1
// => Output: Adding loyalty points for customer C1

Kotlin:

import java.time.Instant                                              // => namespace/package import
 
interface DomainEvent                                                 // => interface DomainEvent
 
class EventDispatcher {                                               // => class EventDispatcher
    private val handlers = mutableMapOf<Class<*>, MutableList<(DomainEvent) -> Unit>>() // => handlers declared
 
    @Suppress("UNCHECKED_CAST")                                       // => expression
    fun <T : DomainEvent> register(type: Class<T>, handler: (T) -> Unit) { // => expression
        handlers.getOrPut(type) { mutableListOf() }                   // => handlers.getOrPut() called
            .add { event -> handler(event as T) }                     // => expression
    // => ends block
    }
 
    fun publish(event: DomainEvent) {                                 // => publish method
        handlers[event::class.java]?.forEach { it(event) }            // => expression
        // => Calls each registered handler with the event
    // => ends block
    }
}
 
data class OrderId(val value: String)                                 // => class OrderId
data class CustomerId(val value: String)                              // => class CustomerId
data class OrderPlaced(val orderId: OrderId, val customerId: CustomerId, val occurredAt: Instant) : DomainEvent // => class OrderPlaced
 
class SendConfirmationEmailHandler {                                  // => class SendConfirmationEmailHandler
    fun handle(event: OrderPlaced) {                                  // => handle method
        println("Sending confirmation email for order ${event.orderId.value}") // => output to console
        // => Single responsibility: only email concern lives here
    }
}
 
class UpdateLoyaltyPointsHandler {                                    // => class UpdateLoyaltyPointsHandler
    fun handle(event: OrderPlaced) {                                  // => handle method
        println("Adding loyalty points for customer ${event.customerId.value}") // => output to console
    }
}
 
// ── Wire up and trigger ───────────────────────────────────────────────────────
val dispatcher = EventDispatcher()                                    // => dispatcher initialised
dispatcher.register(OrderPlaced::class.java, SendConfirmationEmailHandler()::handle) // => dispatcher.register() called
dispatcher.register(OrderPlaced::class.java, UpdateLoyaltyPointsHandler()::handle) // => dispatcher.register() called
 
val event = OrderPlaced(OrderId("O1"), CustomerId("C1"), Instant.now()) // => event initialised
dispatcher.publish(event)                                             // => dispatcher.publish() called
// => Sending confirmation email for order O1
// => Adding loyalty points for customer C1

C#:

using System;                      // => Console.WriteLine, Type
using System.Collections.Generic;  // => Dictionary, List, Action
 
public interface IDomainEvent {}                                      // => IDomainEvent field
// => Marker interface: all domain events implement this; enables Dictionary<Type, ...> dispatch
 
public class EventDispatcher                                          // => class EventDispatcher
// => Routes events to all registered handlers by event type
{
    private readonly Dictionary<Type, List<Action<IDomainEvent>>> _handlers = new(); // => List method
    // => Key = event runtime type; Value = list of handlers registered for that type
 
    public void Register<T>(Action<T> handler) where T : IDomainEvent // => Register method
    // => Generic registration: T must be IDomainEvent; enables type-safe Add()
    {
        var type = typeof(T);                                         // => type initialised
        // => Capture runtime Type object for the event type T
        if (!_handlers.ContainsKey(type)) _handlers[type] = new();    // => precondition check
        // => Lazily create handler list if first registration for this event type
        _handlers[type].Add(e => handler((T)e)); // => Type-safe wrapper
        // => Lambda wraps typed Action<T> as untyped Action<IDomainEvent>; cast is safe
    }
 
    public void Publish(IDomainEvent evt)                             // => Publish method
    // => Dispatches event to all handlers registered for evt's runtime type
    {
        if (_handlers.TryGetValue(evt.GetType(), out var list))       // => precondition check
            // => TryGetValue: returns false if no handlers registered (no exception)
            foreach (var h in list) h(evt); // => Invoke all handlers for this event type
        // => h(evt): calls each registered handler; any order (List does not sort)
    }
}
 
public record OrderId(string Value);                                  // => record OrderId
// => Typed order identity; wraps string for compile-time safety
public record CustomerId(string Value);                               // => record CustomerId
// => Typed customer identity; prevents mixing with OrderId strings
public record OrderPlaced(OrderId OrderId, CustomerId CustomerId, DateTimeOffset OccurredAt) : IDomainEvent; // => record OrderPlaced
// => Immutable domain event record; OccurredAt captures when the placement happened
 
public class SendConfirmationEmailHandler                             // => class SendConfirmationEmailHandler
// => Single-responsibility: only handles OrderPlaced to send email
{
    public void Handle(OrderPlaced evt) =>                            // => Handle method
        Console.WriteLine($"Sending confirmation email for order {evt.OrderId.Value}"); // => output to console
    // => In production: calls email service; here prints to console for demonstration
}
 
public class UpdateLoyaltyPointsHandler                               // => class UpdateLoyaltyPointsHandler
// => Single-responsibility: only handles OrderPlaced to update points
{
    public void Handle(OrderPlaced evt) =>                            // => Handle method
        Console.WriteLine($"Adding loyalty points for customer {evt.CustomerId.Value}"); // => output to console
    // => In production: calls loyalty service; here prints for demonstration
}
 
// ── Wire up ───────────────────────────────────────────────────────────────────
var dispatcher = new EventDispatcher();                               // => dispatcher initialised
// => New dispatcher; no handlers registered yet
dispatcher.Register<OrderPlaced>(new SendConfirmationEmailHandler().Handle); // => expression
// => First handler for OrderPlaced registered
dispatcher.Register<OrderPlaced>(new UpdateLoyaltyPointsHandler().Handle); // => expression
// => Second handler for OrderPlaced registered; dispatcher._handlers[OrderPlaced] = [email, loyalty]
 
dispatcher.Publish(new OrderPlaced(new OrderId("O1"), new CustomerId("C1"), DateTimeOffset.UtcNow)); // => dispatcher.Publish() called
// => Dispatches to both handlers in registration order
// => Sending confirmation email for order O1
// => Adding loyalty points for customer C1

Key Takeaway: A Domain Event Handler is a focused class reacting to exactly one event type. Multiple handlers can subscribe to the same event, keeping each reaction in its own testable unit.

Why It Matters: Without event handlers, every service that needs to react to OrderPlaced must be called directly from the confirmation code path — direct coupling that makes adding new reactions a code change to the core flow. Event handlers invert this dependency. Adding a new reaction (loyalty points, analytics, audit log) is a new class registration, not a modification to existing code. Open/Closed Principle applied at the integration seam.


Example 35: Event-driven cross-aggregate update

Combining Domain Events with event handlers, this example shows a complete flow: Order raises OrderConfirmed, the application service publishes it, and InventoryHandler reacts in a subsequent step — the two aggregates never interact directly.

graph TD
    A["PlaceOrderService"]:::blue --> B["Order.confirm#40;#41;"]:::orange
    B --> C["events: OrderConfirmed"]:::orange
    A --> D["OrderRepository.save"]:::teal
    A --> E["EventDispatcher.publish"]:::blue
    E --> F["InventoryHandler.handle"]:::purple
    F --> G["Inventory.decreaseStock"]:::teal
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px

Java:

import java.math.BigDecimal;                                          // => namespace/package import
import java.time.Instant;                                             // => namespace/package import
import java.util.*;                                                   // => namespace/package import
 
// ── Domain types ──────────────────────────────────────────────────────────────
interface DomainEvent {}                                              // => interface DomainEvent
record OrderId(String value)    { static OrderId gen() { return new OrderId(UUID.randomUUID().toString()); } } // => record OrderId
record CustomerId(String value) {}                                    // => record CustomerId
record ProductId(String value)  {}                                    // => record ProductId
record Money(BigDecimal amount, String currency) {}                   // => record Money
 
record OrderConfirmed(OrderId orderId, List<ProductId> items, Instant at) implements DomainEvent {} // => record OrderConfirmed
 
// ── Order aggregate ───────────────────────────────────────────────────────────
class Order {                                                         // => class Order
    private final OrderId id;                                         // => id field
    private final CustomerId customerId;                              // => customerId field
    private final List<ProductId> items;                              // => expression
    private boolean confirmed = false;                                // => confirmed declared
    private final List<DomainEvent> events = new ArrayList<>();       // => List method
 
    Order(OrderId id, CustomerId cid, List<ProductId> items) {        // => Order() called
        this.id = id; this.customerId = cid; this.items = List.copyOf(items); // => this.id assigned
    // => ends block
    }
 
    public void confirm() {                                           // => confirm method
        if (confirmed) throw new IllegalStateException("Already confirmed"); // => throws if guard fails
        confirmed = true;                                             // => confirmed assigned
        events.add(new OrderConfirmed(id, items, Instant.now()));     // => events.add() called
        // => Event recorded; not yet published — app service publishes after save
    // => ends block
    }
 
    public List<DomainEvent> drainEvents() {                          // => drainEvents method
        var copy = List.copyOf(events);                               // => copy initialised
        events.clear(); // => Events consumed; drain prevents re-publishing
        return copy;                                                  // => returns copy
    // => ends block
    }
// => ends block
}
 
// ── Inventory aggregate ───────────────────────────────────────────────────────
class Inventory {                                                     // => class Inventory
    private final Map<String, Integer> stock = new HashMap<>();       // => Map method
 
    void initialise(ProductId pid, int qty) { stock.put(pid.value(), qty); } // => stock.put() called
 
    void decrease(ProductId pid, int qty) {                           // => expression
        int cur = stock.getOrDefault(pid.value(), 0);                 // => stock.getOrDefault() called
        if (cur < qty) throw new IllegalStateException("No stock: " + pid); // => throws if guard fails
        stock.put(pid.value(), cur - qty);                            // => stock.put() called
    }
 
    int get(ProductId pid) { return stock.getOrDefault(pid.value(), 0); } // => stock.getOrDefault() called
}
 
// ── Event dispatcher (minimal) ─────────────────────────────────────────────────
@FunctionalInterface interface EventHandler<T> { void handle(T event); } // => interface EventHandler
 
class EventDispatcher {                                               // => class EventDispatcher
    private final Map<Class<?>, List<Object>> handlers = new HashMap<>(); // => Map method
 
    @SuppressWarnings("unchecked")                                    // => expression
    <T extends DomainEvent> void register(Class<T> t, EventHandler<T> h) { // => expression
        handlers.computeIfAbsent(t, k -> new ArrayList<>()).add(h);   // => handlers.computeIfAbsent() called
    }
 
    @SuppressWarnings("unchecked")                                    // => expression
    void publish(DomainEvent event) {                                 // => expression
        for (Object h : handlers.getOrDefault(event.getClass(), List.of())) // => iteration over collection
            ((EventHandler<DomainEvent>) h).handle(event);            // => expression
    }
}
 
// ── Application service: orchestrates the cross-aggregate flow ─────────────────
class PlaceOrderService {                                             // => class PlaceOrderService
    private final Map<String, Order> orderStore = new HashMap<>();    // => Map method
    private final Inventory inventory;                                // => inventory field
    private final EventDispatcher dispatcher;                         // => dispatcher field
 
    PlaceOrderService(Inventory inv, EventDispatcher dispatcher) {    // => PlaceOrderService() called
        this.inventory = inv; this.dispatcher = dispatcher;           // => this.inventory assigned
    }
 
    void confirmOrder(OrderId id) {                                   // => expression
        Order order = orderStore.get(id.value());                     // => orderStore.get() called
        order.confirm();                          // => State change + event collected
        orderStore.put(id.value(), order);        // => Persist (step 1)
        order.drainEvents().forEach(dispatcher::publish); // => Publish AFTER save (step 2)
        // => If publish fails, save already happened; retry mechanism handles this
    }
 
    void saveNew(Order order) { orderStore.put(order.id.value(), order); } // => orderStore.put() called
    Order getOrder(OrderId id) { return orderStore.get(id.value()); } // => orderStore.get() called
}
 
// ── Wire up and run ───────────────────────────────────────────────────────────
var inventory  = new Inventory();                                     // => inventory initialised
inventory.initialise(new ProductId("P1"), 5); // => P1 stock = 5
 
var dispatcher = new EventDispatcher();                               // => dispatcher initialised
dispatcher.register(OrderConfirmed.class, (OrderConfirmed e) -> {     // => dispatcher.register() called
    for (ProductId pid : e.items()) inventory.decrease(pid, 1);       // => iteration over collection
    // => Handler decreases stock for each confirmed item
});
 
var service = new PlaceOrderService(inventory, dispatcher);           // => service initialised
var order   = new Order(OrderId.gen(), new CustomerId("C1"),          // => order initialised
                        List.of(new ProductId("P1"), new ProductId("P1"))); // => List.of() called
service.saveNew(order);                                               // => service.saveNew() called
service.confirmOrder(order.id);                                       // => service.confirmOrder() called
 
int remaining = inventory.get(new ProductId("P1")); // => 3 (5 - 2)

Kotlin:

import java.math.BigDecimal; import java.time.Instant; import java.util.UUID // => namespace/package import
 
interface DomainEvent                                                 // => interface DomainEvent
// => Marker interface; all domain events implement this in Kotlin
 
data class OrderId(val value: String) { companion object { fun gen() = OrderId(UUID.randomUUID().toString()) } } // => class OrderId
// => gen() produces a new UUID-based id; companion object is Kotlin's static factory pattern
 
data class CustomerId(val value: String)                              // => class CustomerId
// => Typed identity; prevents passing OrderId where CustomerId is expected
 
data class ProductId(val value: String)                               // => class ProductId
// => Typed product identity; same pattern as CustomerId
 
data class OrderConfirmed(val orderId: OrderId, val items: List<ProductId>, val at: Instant) : DomainEvent // => class OrderConfirmed
// => Immutable event data class; captures which order confirmed and what items were included
 
class Order(val id: OrderId, val customerId: CustomerId, val items: List<ProductId>) { // => class Order
    private var confirmed = false                                     // => confirmed declared
    // => Private mutable flag; Confirm() is the only method that may set this to true
    private val _events   = mutableListOf<DomainEvent>()              // => events declared
    // => Internal queue; not exposed; app service drains after saving
 
    fun confirm() {                                                   // => confirm method
        check(!confirmed) { "Already confirmed" }                     // => precondition check
        // => check() throws IllegalStateException if already confirmed; prevents double-confirm
        confirmed = true                                              // => confirmed assigned
        // => State change first; event records the new state, not the intent
        _events.add(OrderConfirmed(id, items, Instant.now()))         // => _events.add() called
        // => Event queued; Instant.now() captures the confirmation timestamp
    }
 
    fun drainEvents(): List<DomainEvent> {                            // => drainEvents method
        val copy = _events.toList(); _events.clear(); return copy     // => copy initialised
        // => Returns snapshot and clears; prevents re-publication
        // => toList() copies the list before clear; safe from ConcurrentModificationException
    }
}
 
class Inventory {                                                     // => class Inventory
    private val stock = mutableMapOf<String, Int>()                   // => stock declared
    // => Key = product id string; value = available unit count
 
    fun initialise(pid: ProductId, qty: Int) { stock[pid.value] = qty } // => initialise method
    // => Sets initial stock level; overwrites if called again with same product
 
    fun decrease(pid: ProductId, qty: Int) {                          // => decrease method
        val cur = stock[pid.value] ?: 0                               // => cur initialised
        // => cur = current stock, or 0 if product not found in map
        check(cur >= qty) { "No stock: ${pid.value}" }                // => precondition check
        // => Domain rule: cannot decrease below zero; check() throws IllegalStateException
        stock[pid.value] = cur - qty                                  // => expression
        // => In-place update; stock[P1] = 5 - 2 = 3 for two P1 items
    }
    fun get(pid: ProductId) = stock[pid.value] ?: 0                   // => get method
    // => Returns current stock or 0 if product not in map
}
 
class EventDispatcher {                                               // => class EventDispatcher
    private val handlers = mutableMapOf<Class<*>, MutableList<(DomainEvent) -> Unit>>() // => handlers declared
    // => Key = event class; value = list of handlers registered for that type
 
    @Suppress("UNCHECKED_CAST")                                       // => expression
    fun <T : DomainEvent> register(type: Class<T>, handler: (T) -> Unit) { // => expression
        handlers.getOrPut(type) { mutableListOf() }.add { handler(it as T) } // => handlers.getOrPut() called
        // => getOrPut lazily creates list; cast is safe because type parameter constrains T
    }
    fun publish(event: DomainEvent) { handlers[event::class.java]?.forEach { it(event) } } // => publish method
    // => Dispatches to all registered handlers for this event's runtime class
}
 
// ── Application service ───────────────────────────────────────────────────────
class PlaceOrderService(private val inventory: Inventory, private val dispatcher: EventDispatcher) { // => class PlaceOrderService
    private val store = mutableMapOf<String, Order>()                 // => store declared
    // => In-memory store; production uses a real OrderRepository
 
    fun saveNew(order: Order) { store[order.id.value] = order }       // => saveNew method
    // => Inserts order into store by id key; no duplicate check for simplicity
 
    fun confirmOrder(id: OrderId) {                                   // => confirmOrder method
        val order = store[id.value] ?: error("Order not found")       // => order initialised
        // => error() throws IllegalStateException with the message; null-safe via ?:
        order.confirm()                                               // => order.confirm() called
        // => Domain logic: state change + event queued inside aggregate
        store[id.value] = order                           // => Persist
        order.drainEvents().forEach(dispatcher::publish)  // => Then publish
        // => Correct ordering: save before publish; reversal would create phantom events
    }
    fun getOrder(id: OrderId) = store[id.value]                       // => getOrder method
    // => Nullable return; caller handles null if order not found
}
 
// ── Wire up ───────────────────────────────────────────────────────────────────
val inventory  = Inventory().apply { initialise(ProductId("P1"), 5) } // => inventory initialised
// => inventory.stock = {P1: 5}; apply{} block runs on the newly constructed Inventory
 
val dispatcher = EventDispatcher().apply {                            // => dispatcher initialised
    register(OrderConfirmed::class.java) { e: OrderConfirmed ->       // => register() called
        e.items.forEach { inventory.decrease(it, 1) }                 // => inventory.decrease() called
        // => For each confirmed item, decrease stock by 1
    }
}
// => One handler registered; OrderConfirmed fires it
 
val service = PlaceOrderService(inventory, dispatcher)                // => service initialised
val order   = Order(OrderId.gen(), CustomerId("C1"), listOf(ProductId("P1"), ProductId("P1"))) // => order initialised
// => order has two P1 items; total stock decrease will be 2
 
service.saveNew(order)                                                // => service.saveNew() called
// => order stored in in-memory store
service.confirmOrder(order.id)                                        // => service.confirmOrder() called
// => confirm() → OrderConfirmed queued → persisted → event dispatched → handler fires → stock decreases twice
 
val remaining = inventory.get(ProductId("P1")) // => 3
// => 5 (initial) - 1 - 1 (two P1 items) = 3 remaining in stock

C#:

using System; using System.Collections.Generic; using System.Linq;    // => namespace/package import
 
public interface IDomainEvent {}                                      // => IDomainEvent field
// => Marker interface; all domain events implement this
 
public record OrderId(string Value) { public static OrderId Gen() => new(Guid.NewGuid().ToString()); } // => record OrderId
// => Gen() produces a globally unique order id; GUID string is the stable identity
 
public record CustomerId(string Value);                               // => record CustomerId
// => Typed customer identity; prevents confusion with OrderId strings
 
public record ProductId(string Value);                                // => record ProductId
// => Typed product identity; compiler rejects passing OrderId where ProductId is expected
 
public record OrderConfirmed(OrderId OrderId, List<ProductId> Items, DateTimeOffset At) : IDomainEvent; // => record OrderConfirmed
// => Immutable event record; captures what was confirmed and when
 
public class Order(OrderId id, CustomerId customerId, List<ProductId> items) // => class Order
{
    public  OrderId       Id         { get; } = id;                   // => Id field
    // => Id is read-only; identity fixed at construction, never reassigned
    private bool          _confirmed;                                 // => confirmed field
    // => Private mutable state; only Confirm() may flip this to true
    private readonly List<IDomainEvent> _events = new();              // => List method
    // => Internal event queue; not exposed directly to callers
 
    public void Confirm()                                             // => Confirm method
    {
        if (_confirmed) throw new InvalidOperationException("Already confirmed"); // => throws if guard fails
        // => Guard: double-confirm rejected; idempotency not provided here
        _confirmed = true;                                            // => _confirmed assigned
        // => State mutation; happens before event is recorded (event reflects new reality)
        _events.Add(new OrderConfirmed(Id, items, DateTimeOffset.UtcNow)); // => _events.Add() called
        // => OrderConfirmed event queued; not published until app service drains
    }
 
    public List<IDomainEvent> DrainEvents()                           // => DrainEvents method
    {
        var copy = _events.ToList(); _events.Clear(); return copy;    // => copy initialised
        // => Drains and clears; safe to call multiple times
        // => Returns snapshot; caller owns the returned list
    }
}
 
public class Inventory                                                // => class Inventory
{
    private readonly Dictionary<string, int> _stock = new();          // => Dictionary method
    // => String key = product id value; int value = available units
 
    public void Initialise(ProductId pid, int qty) => _stock[pid.Value] = qty; // => Initialise method
    // => Sets initial stock level; used during setup
 
    public void Decrease(ProductId pid, int qty)                      // => Decrease method
    {
        var cur = _stock.GetValueOrDefault(pid.Value, 0);             // => cur initialised
        // => cur = current stock, or 0 if product not found
        if (cur < qty) throw new InvalidOperationException($"No stock: {pid.Value}"); // => throws if guard fails
        // => Invariant: cannot reduce stock below zero
        _stock[pid.Value] = cur - qty;                                // => expression
        // => Stock updated in place; no domain event raised here (simple example)
    }
    public int Get(ProductId pid) => _stock.GetValueOrDefault(pid.Value, 0); // => Get method
    // => Returns current stock or 0 if unknown product
}
 
public class EventDispatcher                                          // => class EventDispatcher
{
    private readonly Dictionary<Type, List<Action<IDomainEvent>>> _h = new(); // => List method
    // => Key = event type; value = list of handlers registered for that type
 
    public void Register<T>(Action<T> handler) where T : IDomainEvent // => Register method
    {
        var t = typeof(T);                                            // => t initialised
        // => t is the concrete event type used as dictionary key
        if (!_h.ContainsKey(t)) _h[t] = new();                        // => precondition check
        // => Lazy initialise handler list for this event type
        _h[t].Add(e => handler((T)e));                                // => expression
        // => Wrap typed handler in untyped Action; cast is safe due to type constraint
    }
    public void Publish(IDomainEvent evt)                             // => Publish method
    {
        if (_h.TryGetValue(evt.GetType(), out var list)) list.ForEach(h => h(evt)); // => precondition check
        // => Dispatches to all handlers registered for this exact event type
    }
}
 
public class PlaceOrderService(Inventory inventory, EventDispatcher dispatcher) // => class PlaceOrderService
{
    private readonly Dictionary<string, Order> _store = new();        // => Dictionary method
    // => In-memory store; production would use a real repository
 
    public void SaveNew(Order o) => _store[o.Id.Value] = o;           // => SaveNew method
    // => Inserts order into store; no duplicate check for brevity
 
    public void ConfirmOrder(OrderId id)                              // => ConfirmOrder method
    {
        var order = _store[id.Value];                                 // => order initialised
        // => Load aggregate; throws KeyNotFoundException if id not found
        order.Confirm();                                              // => order.Confirm() called
        // => Domain logic: state changes + event collected inside aggregate
        _store[id.Value] = order;                           // => Persist
        order.DrainEvents().ForEach(dispatcher.Publish);    // => Publish after save
        // => Correct ordering: save first, then publish; reversal would produce phantom events
    }
}
 
// ── Wire up ───────────────────────────────────────────────────────────────────
var inv = new Inventory(); inv.Initialise(new ProductId("P1"), 5);    // => inv initialised
// => inv._stock = {P1: 5}; initial stock level set
 
var disp = new EventDispatcher();                                     // => disp initialised
disp.Register<OrderConfirmed>(e => { foreach (var p in e.Items) inv.Decrease(p, 1); }); // => inv.Decrease() called
// => Handler registered: on OrderConfirmed, decrease stock by 1 per item
 
var svc   = new PlaceOrderService(inv, disp);                         // => svc initialised
var order = new Order(OrderId.Gen(), new CustomerId("C1"),            // => order initialised
                      new List<ProductId> { new("P1"), new("P1") });  // => expression
// => order has two P1 items; total stock decrease will be 2
svc.SaveNew(order);                                                   // => svc.SaveNew() called
// => order saved to in-memory store
svc.ConfirmOrder(order.Id);                                           // => svc.ConfirmOrder() called
// => order.Confirm() called → OrderConfirmed event → handler fires → stock decreased twice
 
int remaining = inv.Get(new ProductId("P1")); // => 3
// => 5 (initial) - 2 (two P1 items confirmed) = 3 remaining

Key Takeaway: Save the aggregate first, then publish events. The two operations must be ordered: events signal facts that have already persisted, not intentions that might fail.

Why It Matters: Publishing before saving creates a window where the event fires but the aggregate state never persists (e.g., database failure after publish). Downstream systems then act on a phantom event. Publish-after-save — combined with an outbox pattern in production — ensures events only fire for changes that durably committed. This ordering discipline eliminates an entire class of distributed-systems consistency bugs.


Rich Model and Invariants (Examples 36-39)

Example 36: Anemic vs rich model — anti-pattern + fix

An anemic model has data classes with no behaviour and services holding all the logic. A rich model moves behaviour into the entity itself, enforcing invariants close to the data that governs them.

graph LR
    subgraph ANEMIC["Anemic Model"]
        A1["Order<br/>status, total"]:::brown --> A2["OrderService<br/>cancelOrder#40;order#41;"]:::brown
    end
    subgraph RICH["Rich Model"]
        B1["Order<br/>cancel#40;#41;<br/>confirm#40;#41;"]:::teal
    end
 
    classDef brown fill:#CA9161,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px

Anemic model (anti-pattern):

import java.math.BigDecimal;                                          // => namespace/package import
 
enum OrderStatus { PENDING, CONFIRMED, CANCELLED }                    // => enum OrderStatus
// => Three states; transitions between them are the business rules
record Money(BigDecimal amount, String currency) {}                   // => record Money
// => Value type; no domain logic here — just data
 
// Anti-pattern: Order is just a data bag — no behaviour, no invariant protection
class AnemicOrder {                                                   // => class AnemicOrder
    public OrderStatus status;                                        // => status field
    // => public and mutable: ANY caller can set status to any value without a guard
    public Money total;                                               // => total field
    // => No validation; a negative total Money can be assigned silently
 
    public AnemicOrder(OrderStatus status, Money total) {             // => AnemicOrder method
        this.status = status; this.total = total;                     // => this.status assigned
        // => No invariant check here — anyone can construct an invalid AnemicOrder
    }
}
 
// All business logic leaks into a service — scattered, hard to test together
class AnemicOrderService {                                            // => class AnemicOrderService
    public void cancel(AnemicOrder order) {                           // => cancel method
        // => The cancellation rule lives here, not in Order
        // => Every caller that needs to cancel must remember to call this service
        if (order.status == OrderStatus.CONFIRMED || order.status == OrderStatus.PENDING) { // => precondition check
            order.status = OrderStatus.CANCELLED;                     // => order.status assigned
            // => Direct mutation: no event recorded, no guard against double-cancel
        }
        // => If caller sets order.status = CANCELLED directly, this service is bypassed entirely
    }
}

Rich model (correct DDD approach):

import java.math.BigDecimal;                                          // => namespace/package import
 
enum OrderStatus { PENDING, CONFIRMED, CANCELLED }                    // => enum OrderStatus
// => Same states; but the Order class owns the valid transitions between them
record Money(BigDecimal amount, String currency) {}                   // => record Money
 
// Rich model: Order owns its transitions and enforces its own invariants
class RichOrder {                                                     // => class RichOrder
    private OrderStatus status;                                       // => status field
    // => private: external code cannot set status directly — must call confirm() or cancel()
    private final Money total;                                        // => total field
    // => final: total is immutable after construction
 
    public RichOrder(Money total) {                                   // => RichOrder method
        if (total == null || total.amount().compareTo(BigDecimal.ZERO) < 0) // => precondition check
            throw new IllegalArgumentException("Invalid total");      // => throws if guard fails
        // => Invariant enforced at construction: no negative or null total can exist
        this.status = OrderStatus.PENDING;                            // => this.status assigned
        // => New orders start PENDING; no caller can bypass this default
        this.total  = total;                                          // => this.total assigned
    }
 
    public void confirm() {                                           // => confirm method
        if (status != OrderStatus.PENDING)                            // => precondition check
            throw new IllegalStateException("Only PENDING orders can be confirmed; current: " + status); // => throws if guard fails
        // => Guard: illegal transition rejected with a clear message
        status = OrderStatus.CONFIRMED;                               // => status assigned
        // => Transition happens inside Order; one place to add events, logging, metrics
    }
 
    public void cancel() {                                            // => cancel method
        if (status == OrderStatus.CANCELLED)                          // => precondition check
            throw new IllegalStateException("Already cancelled");     // => throws if guard fails
        // => Guard: double-cancel caught here — no scattered if-checks in callers
        status = OrderStatus.CANCELLED;                               // => status assigned
        // => Domain rule in one place; a change to this rule requires editing only here
    }
 
    public OrderStatus getStatus() { return status; }                 // => getStatus method
    // => Read-only accessor; callers observe state but cannot change it directly
}
 
// ── Demonstration ─────────────────────────────────────────────────────────────
var order = new RichOrder(new Money(new BigDecimal("99.00"), "USD")); // => order initialised
// => order.status = PENDING (set in constructor)
order.confirm();                                                      // => order.confirm() called
// => order.status = CONFIRMED; transition validated inside Order
order.cancel();                                                       // => order.cancel() called
// => order.status = CANCELLED; transition validated inside Order
// order.confirm(); // => Would throw IllegalStateException: Only PENDING orders can be confirmed
// order.status = OrderStatus.PENDING; // => compile error: status is private

Kotlin — BAD (anemic model):

import java.math.BigDecimal                                           // => namespace/package import
 
enum class OrderStatus { PENDING, CONFIRMED, CANCELLED }              // => enum class
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
// => Money is a plain data holder; no validation enforced here
 
// Anti-pattern: AnemicOrder is a data bag with public mutable fields
class AnemicOrder(                                                    // => class AnemicOrder
    var status: OrderStatus,     // => var + public: any caller can set this without guard
    var total: Money             // => var + public: negative total accepted silently
)
// => No invariant checks; no business logic inside the class at all
 
// All logic leaked into a separate service
class AnemicOrderService {                                            // => class AnemicOrderService
    fun cancel(order: AnemicOrder) {                                  // => cancel method
        // => Rule lives here, not in Order; must know to call this service
        if (order.status == OrderStatus.CONFIRMED || order.status == OrderStatus.PENDING) { // => precondition check
            order.status = OrderStatus.CANCELLED                      // => order.status assigned
            // => Direct mutation; no event, no guard against double-cancel
        }
        // => Caller could also set order.status = CANCELLED directly — service is bypassed
    }
}

Kotlin — GOOD (rich model):

import java.math.BigDecimal                                           // => namespace/package import
 
enum class OrderStatus { PENDING, CONFIRMED, CANCELLED }              // => enum class
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
 
// Kotlin rich model: private var; transitions as methods with guards
class Order(total: Money) {                                           // => class Order
    var status: OrderStatus = OrderStatus.PENDING                     // => expression
        private set // => External code can read but not set directly
 
    val total: Money                                                  // => expression
 
    init {                                                            // => expression
        require(total.amount >= BigDecimal.ZERO) { "Negative total not allowed" } // => precondition check
        // => Invariant enforced in init; no invalid Order can be constructed
        this.total = total                                            // => this.total assigned
    }
 
    fun confirm() {                                                   // => confirm method
        check(status == OrderStatus.PENDING) { "Cannot confirm; current status: $status" } // => precondition check
        // => Guard: illegal transition rejected with a clear message
        status = OrderStatus.CONFIRMED // => Guarded transition
    }
 
    fun cancel() {                                                    // => cancel method
        check(status != OrderStatus.CANCELLED) { "Already cancelled" } // => precondition check
        // => Guard: double-cancel caught here
        status = OrderStatus.CANCELLED                                // => status assigned
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val order = Order(Money(BigDecimal("99.00"), "USD"))                  // => order initialised
// => order.status = PENDING (set in constructor)
order.confirm() // => status = CONFIRMED; guard passed
order.cancel()  // => status = CANCELLED; guard passed
// order.status = OrderStatus.PENDING  // => Compile error: private set

C# — BAD (anemic model):

using System;                                                         // => namespace/package import
 
public enum OrderStatus { Pending, Confirmed, Cancelled }             // => OrderStatus field
public record Money(decimal Amount, string Currency);                 // => record Money
// => Money holds values; no validation enforced here
 
// Anti-pattern: AnemicOrder — public mutable properties, no behaviour
public class AnemicOrder                                              // => class AnemicOrder
// => begins block
{
    public OrderStatus Status { get; set; }  // => public set: callers can assign any status freely
    public Money Total { get; set; }         // => public set: negative total accepted silently
 
    public AnemicOrder(OrderStatus status, Money total)               // => AnemicOrder method
    // => begins block
    {
        Status = status; Total = total;                               // => Status assigned
        // => No invariant check; invalid AnemicOrder can be constructed
    // => ends block
    }
}
 
// All logic leaked into service — scattered, hard to find all cancellation paths
public class AnemicOrderService                                       // => class AnemicOrderService
{
    public void Cancel(AnemicOrder order)                             // => Cancel method
    {
        // => Rule lives here, not in Order; must know to call this method
        if (order.Status is OrderStatus.Confirmed or OrderStatus.Pending) // => precondition check
        {
            order.Status = OrderStatus.Cancelled;                     // => order.Status assigned
            // => Direct mutation; no event, no guard against double-cancel
        }
        // => Caller can set order.Status = Cancelled directly — service bypassed
    }
}

C# — GOOD (rich model):

using System;                                                         // => namespace/package import
 
public enum OrderStatus { Pending, Confirmed, Cancelled }             // => OrderStatus field
public record Money(decimal Amount, string Currency);                 // => record Money
 
public class Order                                                    // => class Order
{
    public  OrderStatus Status { get; private set; } = OrderStatus.Pending; // => Status field
    public  Money       Total  { get; }                               // => Total field
    // => private set: callers cannot reassign Status; only Order can
 
    public Order(Money total)                                         // => Order method
    {
        if (total.Amount < 0) throw new ArgumentException("Negative total not allowed"); // => throws if guard fails
        // => Invariant enforced at construction; no negative total Order can exist
        Total = total;                                                // => Total assigned
    }
 
    public void Confirm()                                             // => Confirm method
    {
        if (Status != OrderStatus.Pending)                            // => precondition check
            throw new InvalidOperationException($"Cannot confirm; current: {Status}"); // => throws if guard fails
        // => Guard: illegal transition rejected with clear message
        Status = OrderStatus.Confirmed;                               // => Status assigned
        // => Transition validated and recorded inside Order
    }
 
    public void Cancel()                                              // => Cancel method
    {
        if (Status == OrderStatus.Cancelled)                          // => precondition check
            throw new InvalidOperationException("Already cancelled"); // => throws if guard fails
        // => Guard: double-cancel caught here
        Status = OrderStatus.Cancelled;                               // => Status assigned
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var order = new Order(new Money(99m, "USD"));                         // => order initialised
// => order.Status = Pending (set in constructor)
order.Confirm(); // => Status = Confirmed; guard passed
order.Cancel();  // => Status = Cancelled; guard passed
// order.Status = OrderStatus.Pending; // => Compile error: private set

Key Takeaway: Move domain logic into the aggregate. A rich model makes illegal transitions impossible by design; an anemic model relies on every caller remembering to apply the rules.

Why It Matters: Anemic models require every service, controller, and test to duplicate business rules. When a rule changes, every duplication must change too — and one missed callsite creates a production bug. Rich models centralise the rule in one class, tested once. The model also communicates intent: reading order.cancel() explains what happens; reading service.cancelOrder(order) requires reading the service too.


Example 37: Encapsulating mutable collections in aggregate (defensive copy)

An aggregate must not expose its internal mutable collections directly. Callers who receive a live List can modify the aggregate's state without going through the aggregate's methods, bypassing invariant checks.

Java:

import java.util.*;                                                   // => namespace/package import
import java.math.BigDecimal;                                          // => namespace/package import
 
record ProductId(String value) {}                                     // => record ProductId
 
// ── Anti-pattern: exposing mutable internal list ──────────────────────────────
class LeakyOrder {                                                    // => class LeakyOrder
    private final List<ProductId> items = new ArrayList<>();          // => List method
    // => private field, but...
    public List<ProductId> getItems() { return items; } // => Returns live reference!
}
 
// ── Usage of anti-pattern — caller can break invariants ──────────────────────
LeakyOrder leaky = new LeakyOrder();                                  // => expression
leaky.getItems().add(new ProductId("P1")); // => Bypasses any add logic on Order
// => No validation, no event, no guard — caller mutated the aggregate directly
 
// ── Correct: defensive copies ─────────────────────────────────────────────────
class Order {                                                         // => class Order
    private final List<ProductId> items = new ArrayList<>();          // => List method
    private static final int MAX_ITEMS = 50;                          // => MAX_ITEMS declared
    // => Aggregate invariant: an order cannot have more than 50 items
 
    public void addItem(ProductId pid) {                              // => addItem method
        if (items.size() >= MAX_ITEMS)                                // => precondition check
            throw new IllegalStateException("Order cannot exceed " + MAX_ITEMS + " items"); // => throws if guard fails
        if (items.contains(pid))                                      // => precondition check
            throw new IllegalStateException("Duplicate item: " + pid.value()); // => throws if guard fails
        items.add(pid); // => Only this method mutates the list; invariant enforced
    }
 
    public List<ProductId> getItems() {                               // => getItems method
        return Collections.unmodifiableList(items); // => Read-only view; mutation throws
        // => Alternative: return List.copyOf(items) for a full snapshot
    }
 
    public int itemCount() { return items.size(); }                   // => itemCount method
}
 
// ── Correct usage ─────────────────────────────────────────────────────────────
var order = new Order();                                              // => order initialised
order.addItem(new ProductId("P1")); // => items = [P1]; invariants checked
order.addItem(new ProductId("P2")); // => items = [P1, P2]
 
List<ProductId> snapshot = order.getItems();                          // => order.getItems() called
// snapshot.add(new ProductId("P3")); // => Throws UnsupportedOperationException — cannot mutate

Kotlin:

data class ProductId(val value: String)                               // => class ProductId
 
class Order {                                                         // => class Order
    private val _items = mutableListOf<ProductId>()                   // => items declared
    private val MAX_ITEMS = 50                                        // => MAX_ITEMS declared
 
    val items: List<ProductId> get() = _items.toList()                // => _items.toList() called
    // => toList() returns a new immutable copy each time; caller cannot affect _items
 
    fun addItem(pid: ProductId) {                                     // => addItem method
        check(_items.size < MAX_ITEMS) { "Order cannot exceed $MAX_ITEMS items" } // => precondition check
        check(!_items.contains(pid)) { "Duplicate item: ${pid.value}" } // => precondition check
        _items.add(pid) // => Only path to mutate the list
    }
 
    fun itemCount() = _items.size                                     // => itemCount method
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val order = Order()                                                   // => order initialised
order.addItem(ProductId("P1")) // => _items = [P1]
order.addItem(ProductId("P2")) // => _items = [P1, P2]
 
val snapshot = order.items     // => Immutable copy: List<ProductId>
// snapshot is not live — changes to order.items after this point don't affect snapshot

C#:

using System;                                                         // => namespace/package import
using System.Collections.Generic;                                     // => namespace/package import
 
public record ProductId(string Value);                                // => record ProductId
 
public class Order                                                    // => class Order
// => begins block
{
    private readonly List<ProductId> _items = new();                  // => List method
    private const int MaxItems = 50;                                  // => MaxItems declared
 
    public IReadOnlyList<ProductId> Items => _items.AsReadOnly();     // => IReadOnlyList method
    // => AsReadOnly() wraps the list in a read-only view — O(1), no copy
 
    public void AddItem(ProductId pid)                                // => AddItem method
    {
        if (_items.Count >= MaxItems)                                 // => precondition check
            throw new InvalidOperationException($"Order cannot exceed {MaxItems} items"); // => throws if guard fails
        if (_items.Contains(pid))                                     // => precondition check
            throw new InvalidOperationException($"Duplicate item: {pid.Value}"); // => throws if guard fails
        _items.Add(pid); // => Single mutation point; all guards pass
    }
 
    public int ItemCount => _items.Count;                             // => ItemCount declared
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var order = new Order();                                              // => order initialised
order.AddItem(new ProductId("P1")); // => Items = [P1]
order.AddItem(new ProductId("P2")); // => Items = [P1, P2]
 
IReadOnlyList<ProductId> view = order.Items;                          // => expression
// view[0] readable; view is IReadOnlyList — no Add/Remove exposed

Key Takeaway: Return unmodifiableList, toList() snapshot, or IReadOnlyList from aggregate collection accessors. Any method that modifies the collection must go through the aggregate's own methods where invariants are enforced.

Why It Matters: Every line of code that holds a live mutable reference to an aggregate's internal collection is a potential invariant violation. When a bug report says "order has 200 items when the limit is 50", the root cause is almost always a leaked mutable reference. Defensive encapsulation makes the aggregate the only code that can change its own collections — invariants then hold by construction, not by convention.


Example 38: Invariants spanning multiple fields

Some invariants involve two or more fields together. When any field changes, all related fields must be re-validated. Centralising this multi-field check in the aggregate prevents callers from leaving the aggregate in a partially invalid state.

Java:

import java.math.BigDecimal;                                          // => namespace/package import
import java.time.LocalDate;                                           // => namespace/package import
 
record Money(BigDecimal amount, String currency) {}                   // => record Money
 
// ── Aggregate with multi-field invariant ──────────────────────────────────────
// Business rule: a discount order must have both a discount amount AND an expiry date;
// neither can exist without the other.
class Order {                                                         // => class Order
    private final Money baseTotal;                                    // => baseTotal field
    private Money   discountAmount;  // => Nullable: no discount means null
    private LocalDate discountExpiry; // => Nullable: only set when discount is active
    // => Invariant: discountAmount != null <=> discountExpiry != null
 
    public Order(Money baseTotal) {                                   // => Order method
        this.baseTotal = baseTotal;                                   // => this.baseTotal assigned
        // => No discount by default; both null satisfies the invariant
    }
 
    // ── Multi-field setter that validates the combined invariant ───────────────
    public void applyDiscount(Money amount, LocalDate expiry) {       // => applyDiscount method
        if (amount == null || expiry == null)                         // => precondition check
            throw new IllegalArgumentException("Both discount amount and expiry are required"); // => throws if guard fails
        if (amount.amount().compareTo(BigDecimal.ZERO) <= 0)          // => precondition check
            throw new IllegalArgumentException("Discount amount must be positive"); // => throws if guard fails
        if (expiry.isBefore(LocalDate.now()))                         // => precondition check
            throw new IllegalArgumentException("Discount expiry must be in the future"); // => throws if guard fails
        // => Cross-field check: valid amount AND valid expiry together
        this.discountAmount = amount;                                 // => this.discountAmount assigned
        this.discountExpiry = expiry;                                 // => this.discountExpiry assigned
        // => Both set atomically; no window where one is set and the other is not
    }
 
    public void removeDiscount() {                                    // => removeDiscount method
        this.discountAmount = null;                                   // => this.discountAmount assigned
        this.discountExpiry = null;                                   // => this.discountExpiry assigned
        // => Both cleared atomically; invariant maintained
    }
 
    public boolean hasDiscount()    { return discountAmount != null; } // => hasDiscount method
    public Money getEffectiveTotal() {                                // => getEffectiveTotal method
        if (!hasDiscount()) return baseTotal;                         // => precondition check
        return new Money(baseTotal.amount().subtract(discountAmount.amount()), baseTotal.currency()); // => returns new Money(baseTotal.amount().s
        // => Real calc; simplified: ignores currency-mismatch guard for brevity
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var order = new Order(new Money(new BigDecimal("100"), "USD"));       // => order initialised
order.applyDiscount(new Money(new BigDecimal("10"), "USD"), LocalDate.now().plusDays(30)); // => order.applyDiscount() called
// => discountAmount = 10 USD, discountExpiry = 30 days from now; invariant holds
 
Money effective = order.getEffectiveTotal(); // => 90.00 USD (100 - 10)
 
// order.applyDiscount(null, LocalDate.now().plusDays(30)); // => throws: amount required
// order.applyDiscount(new Money(BigDecimal.TEN, "USD"), null); // => throws: expiry required

Kotlin:

import java.math.BigDecimal                                           // => namespace/package import
import java.time.LocalDate                                            // => namespace/package import
 
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
 
class Order(val baseTotal: Money) {                                   // => class Order
    private var discountAmount: Money?    = null                      // => expression
    private var discountExpiry: LocalDate? = null                     // => expression
    // => Both null initially; invariant: both set or both null
 
    fun applyDiscount(amount: Money, expiry: LocalDate) {             // => applyDiscount method
        require(amount.amount > BigDecimal.ZERO) { "Discount must be positive" } // => precondition check
        require(!expiry.isBefore(LocalDate.now())) { "Expiry must be in the future" } // => precondition check
        discountAmount = amount   // => Set together
        discountExpiry = expiry   // => Set together — no partial state
    // => ends block
    }
 
    fun removeDiscount() {                                            // => removeDiscount method
        discountAmount = null                                         // => discountAmount assigned
        discountExpiry = null // => Cleared together; invariant maintained
    // => ends block
    }
 
    val hasDiscount: Boolean get() = discountAmount != null           // => expression
 
    fun effectiveTotal(): Money {                                     // => effectiveTotal method
        val disc = discountAmount ?: return baseTotal                 // => disc initialised
        return Money(baseTotal.amount - disc.amount, baseTotal.currency) // => returns Money(baseTotal.amount - disc.
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val order = Order(Money(BigDecimal("100"), "USD"))                    // => order initialised
order.applyDiscount(Money(BigDecimal("10"), "USD"), LocalDate.now().plusDays(30)) // => order.applyDiscount() called
val effective = order.effectiveTotal() // => Money(90.00, USD)

C#:

using System;                                                         // => namespace/package import
 
public record Money(decimal Amount, string Currency);                 // => record Money
 
public class Order                                                    // => class Order
// => begins block
{
    public Money      BaseTotal      { get; }                         // => BaseTotal field
    private Money?    _discountAmount;                                // => expression
    private DateTime? _discountExpiry;                                // => expression
    // => Nullable pair: both null or both set — invariant enforced by ApplyDiscount
 
    public Order(Money baseTotal) { BaseTotal = baseTotal; }          // => Order method
 
    public void ApplyDiscount(Money amount, DateTime expiry)          // => ApplyDiscount method
    // => begins block
    {
        if (amount.Amount <= 0)                                       // => precondition check
            throw new ArgumentException("Discount must be positive"); // => throws if guard fails
        if (expiry <= DateTime.UtcNow)                                // => precondition check
            throw new ArgumentException("Expiry must be in the future"); // => throws if guard fails
        _discountAmount = amount;  // => Atomic: both assigned together
        _discountExpiry = expiry;  // => No state where one is set without the other
    }
 
    public void RemoveDiscount() { _discountAmount = null; _discountExpiry = null; } // => RemoveDiscount method
 
    public bool HasDiscount => _discountAmount is not null;           // => HasDiscount declared
 
    public Money EffectiveTotal =>                                    // => EffectiveTotal declared
        HasDiscount                                                   // => expression
            ? BaseTotal with { Amount = BaseTotal.Amount - _discountAmount!.Amount } // => expression
            : BaseTotal;                                              // => expression
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var order = new Order(new Money(100m, "USD"));                        // => order initialised
order.ApplyDiscount(new Money(10m, "USD"), DateTime.UtcNow.AddDays(30)); // => order.ApplyDiscount() called
var effective = order.EffectiveTotal; // => Money(Amount=90, Currency=USD)

Key Takeaway: Multi-field invariants must be checked and updated atomically within one method. Never expose individual setters for fields that must change together.

Why It Matters: When two correlated fields have separate setters, a caller who sets one without the other leaves the aggregate in an inconsistent state. In a concurrent system, a thread can read the aggregate between two separate setter calls. Atomic methods that set multiple fields together, validated at entry, eliminate this class of race condition and partial-update bug entirely.


Example 39: Time as injected Clock dependency

Hard-coding Instant.now() or DateTime.UtcNow inside domain logic makes the domain untestable for time-sensitive rules (expiry checks, SLA deadlines, discount windows). Inject a Clock so tests can control time.

Java:

import java.time.*;                                                   // => namespace/package import
// => java.time.* provides Instant, Clock, ZoneOffset — all the time abstractions needed
 
// ── Domain: discount expiry check using injected clock ───────────────────────
record Money(java.math.BigDecimal amount, String currency) {}         // => record Money
// => Money Value Object; currency kept alongside amount; self-contained
 
class Order {                                                         // => class Order
    private final Money   total;                                      // => total field
    // => Base price before discount; never mutated after construction
    private final Instant discountExpiry;                             // => discountExpiry field
    // => Absolute UTC timestamp: discount is valid strictly before this instant
    private final Clock   clock;                                      // => clock field
    // => Clock injected at construction time
    // => Production: Clock.systemUTC(); Tests: Clock.fixed(...)
 
    public Order(Money total, Instant discountExpiry, Clock clock) {  // => Order method
        this.total          = total;                                  // => this.total assigned
        // => Store full Money; currency needed for effectiveTotal return
        this.discountExpiry = discountExpiry;                         // => this.discountExpiry assigned
        // => Expiry is domain data: changes when a new promotion is configured
        this.clock          = clock;                                  // => this.clock assigned
        // => Clock is an infrastructure dependency injected via constructor
    }
 
    public boolean isDiscountValid() {                                // => isDiscountValid method
        Instant now = clock.instant();                                // => clock.instant() called
        // => clock.instant() not Instant.now() — uses injected clock; tests can freeze time
        // => Instant.now() would make this method untestable for time-sensitive assertions
        return now.isBefore(discountExpiry);                          // => returns now.isBefore(discountExpiry)
        // => true = we are still within the discount window
        // => false = discount window has closed; full price applies
    }
 
    public Money effectiveTotal(java.math.BigDecimal discountRate) {  // => effectiveTotal method
        if (!isDiscountValid()) return total;                         // => precondition check
        // => Expired discount: return base price; no calculation needed
        java.math.BigDecimal factor = java.math.BigDecimal.ONE.subtract(discountRate); // => ONE.subtract() called
        // => factor = 1.0 - discountRate; e.g. 0.10 discount → factor = 0.90
        java.math.BigDecimal discounted = total.amount().multiply(factor); // => total.amount() called
        // => 100 USD * 0.90 = 90.00 USD
        return new Money(discounted, total.currency());               // => returns new Money(discounted, total.cu
        // => Return new Money with discounted amount; currency is preserved
    }
}
 
// ── Production: real clock ─────────────────────────────────────────────────────
Clock realClock = Clock.systemUTC();                                  // => Clock.systemUTC() called
// => Returns the real wall-clock time; each call returns a different Instant
var prodOrder = new Order(                                            // => prodOrder initialised
    new Money(new java.math.BigDecimal("100"), "USD"),                // => math.BigDecimal() called
    // => Base price is 100 USD
    Instant.now().plusSeconds(3600),                                  // => Instant.now() called
    // => Discount expires in 1 hour from right now
    realClock                                                         // => expression
    // => Production clock: real time flows
);                                                                    // => expression
boolean valid = prodOrder.isDiscountValid();                          // => prodOrder.isDiscountValid() called
// => true: real now is earlier than expiry (1 hour from now)
 
// ── Test: fixed clock ─────────────────────────────────────────────────────────
Clock fixedFuture = Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC); // => Clock.fixed() called
// => Frozen clock: always returns 2030-01-01T00:00:00Z regardless of when the test runs
// => Tests using this clock are deterministic: no flakiness from real system time
var testOrder = new Order(                                            // => testOrder initialised
    new Money(new java.math.BigDecimal("100"), "USD"),                // => math.BigDecimal() called
    Instant.parse("2025-01-01T00:00:00Z"),                            // => Instant.parse() called
    // => Expiry set to 2025; frozen clock is 2030 → expiry is 5 years in the past
    fixedFuture                                                       // => expression
    // => Test clock: frozen at 2030
);                                                                    // => expression
boolean expired = testOrder.isDiscountValid();                        // => testOrder.isDiscountValid() called
// => false: clock returns 2030; expiry was 2025; 2030 is NOT before 2025
// => Test passes without sleeping, mocking static methods, or modifying system time

Kotlin:

import java.math.BigDecimal                                           // => namespace/package import
import java.time.*                                                    // => namespace/package import
 
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
 
class Order(                                                          // => class Order
    val total:          Money,                                        // => expression
    val discountExpiry: Instant,                                      // => expression
    private val clock:  Clock    // => Injected; production uses Clock.systemUTC()
) {                                                                   // => expression
    fun isDiscountValid(): Boolean {                                  // => isDiscountValid method
        val now = clock.instant() // => clock.instant() not Instant.now()
        return now.isBefore(discountExpiry)                           // => returns now.isBefore(discountExpiry)
    // => ends block
    }
 
    fun effectiveTotal(discountRate: BigDecimal): Money {             // => effectiveTotal method
        if (!isDiscountValid()) return total                          // => precondition check
        return Money(total.amount * (BigDecimal.ONE - discountRate), total.currency) // => returns Money(total.amount * (BigDecim
    // => ends block
    }
// => ends block
}
 
// ── Production ─────────────────────────────────────────────────────────────────
val prodOrder = Order(                                                // => prodOrder initialised
    Money(BigDecimal("100"), "USD"),                                  // => Money() called
    Instant.now().plusSeconds(3600),                                  // => Instant.now() called
    Clock.systemUTC()                                                 // => Clock.systemUTC() called
)
val valid = prodOrder.isDiscountValid() // => true
 
// ── Test ──────────────────────────────────────────────────────────────────────
val testClock = Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC) // => testClock initialised
val testOrder = Order(                                                // => testOrder initialised
    Money(BigDecimal("100"), "USD"),                                  // => Money() called
    Instant.parse("2025-01-01T00:00:00Z"), // => Past relative to testClock
    testClock                                                         // => expression
)
val expired = testOrder.isDiscountValid() // => false (2030 > 2025 expiry)

C#:

using System;                                                         // => namespace/package import
 
public record Money(decimal Amount, string Currency);                 // => record Money
 
// ── Abstraction: allows test doubles ─────────────────────────────────────────
public interface IClock { DateTimeOffset UtcNow { get; } }            // => IClock field
public class SystemClock : IClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; } // => class SystemClock
public class FixedClock(DateTimeOffset fixedTime) : IClock { public DateTimeOffset UtcNow => fixedTime; } // => class FixedClock
// => FixedClock: test double; freezes time at construction value
 
public class Order(Money total, DateTimeOffset discountExpiry, IClock clock) // => class Order
{
    public bool IsDiscountValid() => clock.UtcNow < discountExpiry;   // => IsDiscountValid method
    // => Uses injected clock; never DateTimeOffset.UtcNow directly
 
    public Money EffectiveTotal(decimal rate) =>                      // => EffectiveTotal method
        IsDiscountValid()                                             // => IsDiscountValid() called
            ? total with { Amount = total.Amount * (1 - rate) }       // => expression
            : total;                                                  // => expression
}
 
// ── Production ─────────────────────────────────────────────────────────────────
var prodOrder = new Order(                                            // => prodOrder initialised
    new Money(100m, "USD"),                                           // => expression
    DateTimeOffset.UtcNow.AddHours(1),                                // => UtcNow.AddHours() called
    new SystemClock()                                                 // => expression
);                                                                    // => expression
bool valid = prodOrder.IsDiscountValid(); // => true
 
// ── Test ──────────────────────────────────────────────────────────────────────
var fixedClock = new FixedClock(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero)); // => fixedClock initialised
var testOrder  = new Order(                                           // => testOrder initialised
    new Money(100m, "USD"),                                           // => expression
    new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), // => Past
    fixedClock                                                        // => expression
);                                                                    // => expression
bool expired = testOrder.IsDiscountValid(); // => false (2030 > 2025)

Key Takeaway: Inject a Clock abstraction wherever domain logic depends on the current time. Production wires the real clock; tests wire a fixed clock to control time precisely.

Why It Matters: A domain model with Instant.now() hardcoded is impossible to test for time-sensitive paths without sleeping the test or manipulating system time. Injected clocks enable deterministic, fast, parallelisable tests of all time-dependent business rules. The same principle extends to other environmental dependencies — random number generators, ID generators — that should also be injected for testability.


Value Object Refinements (Examples 40-42)

Example 40: Currency-aware Money operations (reject mixed currency)

Money arithmetic must refuse to add or subtract values in different currencies. Silently converting or ignoring the currency mismatch causes financial bugs that are hard to trace. The domain model enforces the rule at the type level.

Java:

import java.math.BigDecimal;                                          // => namespace/package import
import java.util.Objects;                                             // => namespace/package import
// => Objects.hash() used for hashCode; BigDecimal for exact decimal arithmetic
 
// Money Value Object: amount + currency are inseparable
// => final class: no subclassing; immutability cannot be broken by inheritance
// => Arithmetic is only defined when currencies match — domain rule enforced here
public final class Money {                                            // => Money field
    private final BigDecimal amount;                                  // => amount field
    // => private final: immutable after construction; no setter exists
    private final String     currency;                                // => currency field
    // => ISO 4217 code e.g. "USD", "IDR"; stored uppercase for consistent comparison
 
    public Money(BigDecimal amount, String currency) {                // => Money method
        if (amount == null)   throw new IllegalArgumentException("amount required"); // => throws if guard fails
        // => Null check first; prevents NullPointerException in compareTo below
        if (currency == null || currency.isBlank()) throw new IllegalArgumentException("currency required"); // => throws if guard fails
        // => Blank currency ("  ") is as invalid as null; isBlank() catches both
        if (amount.compareTo(BigDecimal.ZERO) < 0)  throw new IllegalArgumentException("negative not allowed"); // => throws if guard fails
        // => Domain rule: Money cannot be negative; reject at construction time
        this.amount   = amount.setScale(2, java.math.RoundingMode.HALF_UP); // => this.amount assigned
        // => Normalise to 2 decimal places; ensures 100 and 100.00 compare as equal
        this.currency = currency.toUpperCase();                       // => this.currency assigned
        // => Uppercase normalisation: "usd" and "USD" represent the same currency
    }
 
    // ── Guard: same currency required for all arithmetic ─────────────────────
    // Private helper: called by every arithmetic method before operating
    private void requireSameCurrency(Money other) {                   // => requireSameCurrency method
        if (!this.currency.equals(other.currency))                    // => precondition check
            throw new IllegalArgumentException(                       // => throws if guard fails
                "Currency mismatch: " + this.currency + " vs " + other.currency); // => expression
        // => Domain rule: USD + IDR is meaningless without an exchange rate
        // => Failing loudly here prevents silent currency-confusion bugs in production
    }
 
    public Money add(Money other) {                                   // => add method
        requireSameCurrency(other);                                   // => requireSameCurrency() called
        // => Guard ensures currencies match before any computation
        return new Money(this.amount.add(other.amount), this.currency); // => returns new Money(this.amount.add(othe
        // => New Money returned; original instances unchanged (Value Object immutability)
    }
 
    public Money subtract(Money other) {                              // => subtract method
        requireSameCurrency(other);                                   // => requireSameCurrency() called
        // => Same guard as add; currencies must match
        BigDecimal result = this.amount.subtract(other.amount);       // => amount.subtract() called
        // => BigDecimal subtraction is exact; no floating-point rounding error
        if (result.compareTo(BigDecimal.ZERO) < 0)                    // => precondition check
            throw new IllegalArgumentException("Subtraction would produce negative Money"); // => throws if guard fails
        // => Domain rule: Money cannot go negative via subtraction either
        return new Money(result, this.currency);                      // => returns new Money(result, this.currenc
        // => New immutable Money with the subtracted amount
    }
 
    public boolean isGreaterThan(Money other) {                       // => isGreaterThan method
        requireSameCurrency(other);                                   // => requireSameCurrency() called
        // => Comparison across currencies is undefined; guard prevents it
        return this.amount.compareTo(other.amount) > 0;               // => returns this.amount.compareTo(other.am
        // => compareTo > 0 means this > other; returns boolean, not int
    }
 
    @Override public boolean equals(Object o) {                       // => expression
        if (!(o instanceof Money m)) return false;                    // => precondition check
        // => Pattern variable 'm': Java 16+ syntax; only reaches next line if cast succeeds
        return amount.compareTo(m.amount) == 0 && currency.equals(m.currency); // => returns amount.compareTo(m.amount) == 
        // => Structural equality: same normalised amount AND same currency
        // => compareTo used for BigDecimal (not equals) to handle 10.00 == 10.0
    }
    @Override public int hashCode() {                                 // => expression
        return Objects.hash(amount.stripTrailingZeros(), currency);   // => returns Objects.hash(amount.stripTrail
        // => stripTrailingZeros ensures 10.00 and 10 produce the same hash
    }
    @Override public String toString() { return amount.toPlainString() + " " + currency; } // => amount.toPlainString() called
    // => "100.00 USD"; toPlainString avoids scientific notation for large amounts
 
    public BigDecimal getAmount()  { return amount; }   // => Read-only accessor
    public String getCurrency()    { return currency; }  // => Read-only accessor
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
Money usd100 = new Money(new BigDecimal("100"), "USD"); // => 100.00 USD
Money usd50  = new Money(new BigDecimal("50"),  "USD"); // => 50.00 USD
Money idr500 = new Money(new BigDecimal("500"), "IDR"); // => 500.00 IDR
 
Money total  = usd100.add(usd50);      // => 150.00 USD (new instance; usd100 unchanged)
Money change = usd100.subtract(usd50); // => 50.00 USD
 
try {                                                                 // => expression
    usd100.add(idr500);                                               // => usd100.add() called
    // => throws: Currency mismatch: USD vs IDR
} catch (IllegalArgumentException e) {                                // => expression
    System.out.println(e.getMessage()); // => Currency mismatch: USD vs IDR
}

Kotlin:

import java.math.BigDecimal                                           // => namespace/package import
import java.math.RoundingMode                                         // => namespace/package import
 
data class Money private constructor(val amount: BigDecimal, val currency: String) { // => class Money
    init {                                                            // => expression
        require(amount >= BigDecimal.ZERO)   { "Negative amount not allowed" } // => precondition check
        require(currency.isNotBlank())        { "Currency required" } // => precondition check
    // => ends block
    }
 
    companion object {                                                // => expression
        fun of(amount: BigDecimal, currency: String) =                // => of method
            Money(amount.setScale(2, RoundingMode.HALF_UP), currency.uppercase()) // => amount.setScale() called
        // => Factory normalises scale and casing before construction
    }
 
    private fun requireSameCurrency(other: Money) =                   // => requireSameCurrency method
        require(currency == other.currency) { "Currency mismatch: $currency vs ${other.currency}" } // => precondition check
 
    operator fun plus(other: Money):  Money { requireSameCurrency(other); return Money(amount + other.amount, currency) } // => expression
    operator fun minus(other: Money): Money {                         // => expression
        requireSameCurrency(other)                                    // => requireSameCurrency() called
        val result = amount - other.amount                            // => result initialised
        require(result >= BigDecimal.ZERO) { "Subtraction yields negative" } // => precondition check
        return Money(result, currency)                                // => returns Money(result, currency)
    }
    operator fun compareTo(other: Money): Int { requireSameCurrency(other); return amount.compareTo(other.amount) } // => amount.compareTo() called
    // => Kotlin operator overloading: money1 + money2, money1 > money2 syntax
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val usd100 = Money.of(BigDecimal("100"), "USD") // => 100.00 USD
val usd50  = Money.of(BigDecimal("50"),  "USD") // => 50.00 USD
val idr500 = Money.of(BigDecimal("500"), "IDR") // => 500.00 IDR
 
val total  = usd100 + usd50   // => 150.00 USD (operator plus)
val change = usd100 - usd50   // => 50.00 USD (operator minus)
 
runCatching { usd100 + idr500 }                                       // => expression
    .onFailure { println(it.message) } // => Currency mismatch: USD vs IDR

C#:

using System;                                                         // => namespace/package import
 
public sealed record Money                                            // => record Money
// => begins block
{
    public decimal Amount   { get; }                                  // => Amount field
    public string  Currency { get; }                                  // => Currency field
 
    public Money(decimal amount, string currency)                     // => Money method
    // => begins block
    {
        if (amount < 0)                         throw new ArgumentException("Negative not allowed"); // => throws if guard fails
        if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException("Currency required"); // => throws if guard fails
        Amount   = Math.Round(amount, 2);       // => Normalise to 2 decimal places
        Currency = currency.ToUpperInvariant();                       // => Currency assigned
    // => ends block
    }
 
    private void RequireSameCurrency(Money other)                     // => RequireSameCurrency method
    // => begins block
    {
        if (Currency != other.Currency)                               // => precondition check
            throw new InvalidOperationException($"Currency mismatch: {Currency} vs {other.Currency}"); // => throws if guard fails
    // => ends block
    }
 
    public static Money operator +(Money a, Money b) { a.RequireSameCurrency(b); return a with { Amount = a.Amount + b.Amount }; } // => method declaration
    public static Money operator -(Money a, Money b)                  // => method declaration
    // => begins block
    {
        a.RequireSameCurrency(b);                                     // => a.RequireSameCurrency() called
        var result = a.Amount - b.Amount;                             // => result initialised
        if (result < 0) throw new InvalidOperationException("Subtraction yields negative"); // => throws if guard fails
        return a with { Amount = result };                            // => returns a with { Amount = result }
    }
    // => C# operator overloading: money1 + money2 syntax in application code
 
    public override string ToString() => $"{Amount:F2} {Currency}";   // => ToString method
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var usd100 = new Money(100m, "USD"); // => 100.00 USD
var usd50  = new Money(50m,  "USD"); // => 50.00 USD
var idr500 = new Money(500m, "IDR"); // => 500.00 IDR
 
var total  = usd100 + usd50;   // => 150.00 USD
var change = usd100 - usd50;   // => 50.00 USD
 
try { _ = usd100 + idr500; }   // => throws InvalidOperationException
catch (InvalidOperationException e) { Console.WriteLine(e.Message); } // => Currency mismatch: USD vs IDR

Key Takeaway: Money operations must verify currency equality before computing. The domain model, not the caller, is responsible for this guard.

Why It Matters: Financial systems that silently add amounts in different currencies produce wrong results without any exception. In multi-currency e-commerce platforms this class of bug causes mis-charged invoices. Making the Money Value Object reject mixed-currency arithmetic at the method level means no application service or controller needs to remember the rule — the type enforces it universally.


Example 41: Quantity Value Object with units

A Quantity Value Object pairs a numeric count with a unit of measure. Adding quantities in different units (kilograms + litres) is as meaningless as adding different currencies. The Value Object enforces unit compatibility.

Java:

import java.math.BigDecimal;                                          // => namespace/package import
 
public final class Quantity {                                         // => Quantity field
    private final BigDecimal value;                                   // => value field
    private final String     unit; // => e.g. "kg", "litre", "piece"
 
    public Quantity(BigDecimal value, String unit) {                  // => Quantity method
        if (value == null || value.compareTo(BigDecimal.ZERO) < 0)    // => precondition check
            throw new IllegalArgumentException("Quantity value must be non-negative"); // => throws if guard fails
        if (unit == null || unit.isBlank())                           // => precondition check
            throw new IllegalArgumentException("Unit required");      // => throws if guard fails
        this.value = value;                                           // => this.value assigned
        this.unit  = unit.toLowerCase();                              // => this.unit assigned
    // => ends block
    }
 
    public static Quantity of(double value, String unit) {            // => of method
        return new Quantity(BigDecimal.valueOf(value), unit); // => Convenience factory
    // => ends block
    }
 
    private void requireSameUnit(Quantity other) {                    // => requireSameUnit method
        if (!this.unit.equals(other.unit))                            // => precondition check
            throw new IllegalArgumentException("Unit mismatch: " + this.unit + " vs " + other.unit); // => throws if guard fails
        // => Domain rule: kg + litre is undefined without a conversion factor
    // => ends block
    }
 
    public Quantity add(Quantity other) {                             // => add method
        requireSameUnit(other);                                       // => requireSameUnit() called
        return new Quantity(this.value.add(other.value), this.unit);  // => returns new Quantity(this.value.add(ot
    // => ends block
    }
 
    public boolean isGreaterThan(Quantity other) {                    // => isGreaterThan method
        requireSameUnit(other);                                       // => requireSameUnit() called
        return this.value.compareTo(other.value) > 0;                 // => returns this.value.compareTo(other.val
    // => ends block
    }
 
    @Override public String toString() { return value.toPlainString() + " " + unit; } // => value.toPlainString() called
    public BigDecimal getValue() { return value; }                    // => getValue method
    public String getUnit()      { return unit; }                     // => getUnit method
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
Quantity a = Quantity.of(3.5, "kg");    // => 3.5 kg
Quantity b = Quantity.of(1.5, "kg");    // => 1.5 kg
Quantity c = Quantity.of(2.0, "litre"); // => 2.0 litre
 
Quantity sum = a.add(b); // => 5.0 kg
 
try {                                                                 // => expression
    a.add(c); // => throws: Unit mismatch: kg vs litre
} catch (IllegalArgumentException e) {                                // => expression
    System.out.println(e.getMessage()); // => Unit mismatch: kg vs litre
}
 
boolean heavy = a.isGreaterThan(b); // => true (3.5 > 1.5, same unit kg)

Kotlin:

import java.math.BigDecimal                                           // => namespace/package import
 
data class Quantity private constructor(val value: BigDecimal, val unit: String) { // => class Quantity
    // => private constructor: callers must use of() factory; no direct Quantity(...)
    init {                                                            // => expression
        require(value >= BigDecimal.ZERO) { "Quantity must be non-negative" } // => precondition check
        // => Rejects negative; a quantity of -3 kg has no domain meaning
        require(unit.isNotBlank())         { "Unit required" }        // => precondition check
        // => Rejects blank unit; "kg", "litre", "piece" are valid; "" is not
    }
 
    companion object {                                                // => expression
        fun of(value: Double, unit: String) = Quantity(BigDecimal.valueOf(value), unit.lowercase()) // => of method
        // => Factory: converts Double to BigDecimal, normalises unit to lowercase
        // => "KG" and "kg" resolve to same unit string after lowercase()
    }
 
    private fun requireSameUnit(other: Quantity) =                    // => requireSameUnit method
        require(unit == other.unit) { "Unit mismatch: $unit vs ${other.unit}" } // => precondition check
    // => Shared guard; throws IllegalArgumentException if units differ
 
    operator fun plus(other: Quantity): Quantity {                    // => expression
        requireSameUnit(other)                                        // => requireSameUnit() called
        // => Guard: ensures both are kg before adding; rejects kg + litre
        return Quantity(value + other.value, unit) // => New Quantity, same unit
        // => copy() not used; private constructor called directly since in companion scope
    }
 
    operator fun compareTo(other: Quantity): Int {                    // => expression
        requireSameUnit(other)                                        // => requireSameUnit() called
        // => Comparison across different units is undefined; guard fires first
        return value.compareTo(other.value)                           // => returns value.compareTo(other.value)
        // => Delegates to BigDecimal.compareTo: returns -1, 0, or 1
    }
 
    override fun toString() = "$value $unit"                          // => toString method
    // => "3.5 kg"; used in println and assertions
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val a = Quantity.of(3.5, "kg")    // => Quantity(value=3.5, unit=kg)
val b = Quantity.of(1.5, "kg")    // => Quantity(value=1.5, unit=kg)
val c = Quantity.of(2.0, "litre") // => Quantity(value=2.0, unit=litre)
 
val sum   = a + b // => 5.0 kg (operator plus; units match)
val heavy = a > b  // => true  (3.5 > 1.5; compareTo returns positive)
 
runCatching { a + c }.onFailure { println(it.message) } // => Unit mismatch: kg vs litre
// => runCatching catches the IllegalArgumentException; onFailure prints the message

C#:

using System;                                                         // => namespace/package import
 
public sealed record Quantity                                         // => record Quantity
{
    public decimal Amount { get; }                                    // => Amount field
    // => get-only: immutable after construction; no mutation via properties
    public string  Unit   { get; }                                    // => Unit field
    // => Normalised to lowercase; "KG" and "kg" are equivalent after construction
 
    public Quantity(decimal amount, string unit)                      // => Quantity method
    {
        if (amount < 0)                      throw new ArgumentException("Must be non-negative"); // => throws if guard fails
        // => Domain rule: negative quantity meaningless; rejected at construction
        if (string.IsNullOrWhiteSpace(unit)) throw new ArgumentException("Unit required"); // => throws if guard fails
        // => IsNullOrWhiteSpace catches null, "", "  " — all invalid unit values
        Amount = amount;                                              // => Amount assigned
        // => Stored as-is; consider Math.Round(amount, 4) for precision normalisation
        Unit   = unit.ToLowerInvariant();                             // => Unit assigned
        // => Normalise case: "KG" stored as "kg" for consistent equality checks
    }
 
    private void RequireSameUnit(Quantity other)                      // => RequireSameUnit method
    {
        if (Unit != other.Unit)                                       // => precondition check
            throw new InvalidOperationException($"Unit mismatch: {Unit} vs {other.Unit}"); // => throws if guard fails
        // => Throws before any arithmetic; prevents silent wrong-unit addition
    }
 
    public static Quantity operator +(Quantity a, Quantity b)         // => method declaration
    {
        a.RequireSameUnit(b);                                         // => a.RequireSameUnit() called
        // => Guard fires if units differ; no arithmetic attempted on mismatched units
        return a with { Amount = a.Amount + b.Amount };               // => returns a with { Amount = a.Amount + b
        // => with-expression: creates new record with updated Amount; b.Amount added to a.Amount
    }
 
    public override string ToString() => $"{Amount} {Unit}";          // => ToString method
    // => "3.5 kg"; used for diagnostics and logging
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var a = new Quantity(3.5m, "kg");    // => Quantity { Amount=3.5, Unit="kg" }
var b = new Quantity(1.5m, "kg");    // => Quantity { Amount=1.5, Unit="kg" }
var c = new Quantity(2.0m, "litre"); // => Quantity { Amount=2.0, Unit="litre" }
 
var sum = a + b; // => Quantity { Amount=5.0, Unit="kg" } (units matched)
 
try { _ = a + c; }                                                    // => expression
// => RequireSameUnit fires: Unit "kg" != "litre"
catch (InvalidOperationException e) { Console.WriteLine(e.Message); } // => Unit mismatch: kg vs litre

Key Takeaway: Pair every numeric value with its unit and reject arithmetic across incompatible units. The unit is as much part of the value as the number.

Why It Matters: Unit-mixing bugs are legendary in engineering failures. In domain models they appear as inventory systems that add kilogram counts to piece counts, or logistics platforms that compare metres to feet. A Quantity Value Object with unit-checking makes these bugs compile-time errors or immediate runtime exceptions, not silent wrong answers discovered in financial reconciliation.


Example 42: Period / DateRange Value Object

A DateRange captures a start and end date as an inseparable pair with its own domain operations: contains(date), overlaps(other), length(). These operations belong on the Value Object, not scattered in services.

Java:

import java.time.LocalDate;                                           // => namespace/package import
import java.time.temporal.ChronoUnit;                                 // => namespace/package import
 
public final class DateRange {                                        // => DateRange field
    private final LocalDate start;                                    // => start field
    private final LocalDate end;                                      // => end field
    // => Invariant: start must be before or equal to end
 
    public DateRange(LocalDate start, LocalDate end) {                // => DateRange method
        if (start == null || end == null) throw new IllegalArgumentException("Dates required"); // => throws if guard fails
        if (start.isAfter(end)) throw new IllegalArgumentException("Start must be <= end; got " + start + " > " + end); // => throws if guard fails
        this.start = start;                                           // => this.start assigned
        this.end   = end;                                             // => this.end assigned
    // => ends block
    }
 
    public static DateRange of(LocalDate start, LocalDate end) { return new DateRange(start, end); } // => of method
 
    public boolean contains(LocalDate date) {                         // => contains method
        return !date.isBefore(start) && !date.isAfter(end);           // => returns !date.isBefore(start) && !date
        // => Inclusive on both ends: [start, end]
    }
 
    public boolean overlaps(DateRange other) {                        // => overlaps method
        return !this.end.isBefore(other.start) && !other.end.isBefore(this.start); // => returns !this.end.isBefore(other.start
        // => Two ranges overlap when neither ends before the other starts
    }
 
    public long lengthInDays() {                                      // => lengthInDays method
        return ChronoUnit.DAYS.between(start, end) + 1; // => Inclusive count
    }
 
    public LocalDate getStart() { return start; }                     // => getStart method
    public LocalDate getEnd()   { return end; }                       // => getEnd method
 
    @Override public String toString() { return "[" + start + ", " + end + "]"; } // => expression
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
DateRange q1 = DateRange.of(LocalDate.of(2025, 1, 1), LocalDate.of(2025, 3, 31)); // => DateRange.of() called
DateRange q2 = DateRange.of(LocalDate.of(2025, 4, 1), LocalDate.of(2025, 6, 30)); // => DateRange.of() called
DateRange spanning = DateRange.of(LocalDate.of(2025, 3, 15), LocalDate.of(2025, 4, 15)); // => DateRange.of() called
 
boolean midQ1   = q1.contains(LocalDate.of(2025, 2, 15)); // => true
boolean noOlap  = q1.overlaps(q2);       // => false (q1 ends Mar 31, q2 starts Apr 1)
boolean yesOlap = q1.overlaps(spanning); // => true (spanning starts Mar 15, inside q1)
long    days    = q1.lengthInDays();     // => 90

Kotlin:

import java.time.LocalDate                                            // => namespace/package import
import java.time.temporal.ChronoUnit                                  // => namespace/package import
 
data class DateRange(val start: LocalDate, val end: LocalDate) {      // => class DateRange
    init {                                                            // => expression
        require(!start.isAfter(end)) { "Start must be <= end; got $start > $end" } // => precondition check
    }
 
    operator fun contains(date: LocalDate) = !date.isBefore(start) && !date.isAfter(end) // => date.isBefore() called
    // => operator fun: allows  date in range  syntax
 
    fun overlaps(other: DateRange) = !end.isBefore(other.start) && !other.end.isBefore(start) // => overlaps method
 
    fun lengthInDays() = ChronoUnit.DAYS.between(start, end) + 1L // => Inclusive
 
    override fun toString() = "[$start, $end]"                        // => toString method
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val q1      = DateRange(LocalDate.of(2025, 1, 1), LocalDate.of(2025, 3, 31)) // => q1 initialised
val q2      = DateRange(LocalDate.of(2025, 4, 1), LocalDate.of(2025, 6, 30)) // => q2 initialised
val spanning = DateRange(LocalDate.of(2025, 3, 15), LocalDate.of(2025, 4, 15)) // => spanning initialised
 
val feb15InQ1 = LocalDate.of(2025, 2, 15) in q1  // => true (operator contains)
val noOlap    = q1.overlaps(q2)                   // => false
val yesOlap   = q1.overlaps(spanning)             // => true
val days      = q1.lengthInDays()                 // => 90

C#:

using System;                                                         // => namespace/package import
 
public sealed record DateRange(DateOnly Start, DateOnly End)          // => record DateRange
// => begins block
{
    // Primary constructor validates invariant via init (C# 12 + record)
    public DateRange : this(Start, End)                               // => method declaration
    {
        if (Start > End) throw new ArgumentException($"Start must be <= End; got {Start} > {End}"); // => throws if guard fails
    }
 
    public bool Contains(DateOnly date) => date >= Start && date <= End; // => Contains method
    // => Inclusive range check
 
    public bool Overlaps(DateRange other) => End >= other.Start && other.End >= Start; // => Overlaps method
 
    public int LengthInDays() => End.DayNumber - Start.DayNumber + 1; // => Inclusive
 
    public override string ToString() => $"[{Start}, {End}]";         // => ToString method
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var q1      = new DateRange(new DateOnly(2025, 1, 1), new DateOnly(2025, 3, 31)); // => q1 initialised
var q2      = new DateRange(new DateOnly(2025, 4, 1), new DateOnly(2025, 6, 30)); // => q2 initialised
var spanning = new DateRange(new DateOnly(2025, 3, 15), new DateOnly(2025, 4, 15)); // => spanning initialised
 
bool midQ1   = q1.Contains(new DateOnly(2025, 2, 15)); // => true
bool noOlap  = q1.Overlaps(q2);       // => false
bool yesOlap = q1.Overlaps(spanning); // => true
int  days    = q1.LengthInDays();     // => 90

Key Takeaway: DateRange is a first-class Value Object. contains and overlaps live on it — not in utility classes or service methods that receive two separate date parameters.

Why It Matters: When date ranges are passed as two separate LocalDate parameters, callers must remember to validate and interpret the pair consistently. A DateRange Value Object moves validation into the constructor, names the concept, and gives it operations. Any business rule involving date intervals — promotional periods, subscription windows, SLA periods — becomes readable and reusable rather than repeated.


Architecture Patterns (Examples 43-46)

Example 43: Layered architecture — package/namespace layout

DDD layered architecture organises code into four layers: Domain (core), Application (use cases), Infrastructure (persistence, messaging), and Presentation (controllers, APIs). Dependencies point inward — outer layers depend on inner layers, never the reverse.

graph TD
    P["Presentation Layer<br/>Controllers / REST"]:::blue
    A["Application Layer<br/>Use Cases / Services"]:::orange
    D["Domain Layer<br/>Entities / VOs / Events"]:::teal
    I["Infrastructure Layer<br/>Repos / DB / Messaging"]:::purple
 
    P --> A
    A --> D
    I --> D
    P --> I
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px

Java (package naming convention):

// ── Package layout mirrors layer boundaries ───────────────────────────────────
// com.example.order
// ├── domain/           ← No dependencies on application or infrastructure
// │   ├── Order.java
// │   ├── OrderId.java
// │   ├── OrderRepository.java   (interface only — no JPA here)
// │   └── OrderConfirmed.java    (domain event)
// ├── application/      ← Depends on domain; no infrastructure imports
// │   └── ConfirmOrderUseCase.java
// ├── infrastructure/   ← Depends on domain (implements interfaces); knows JPA, DB
// │   └── JpaOrderRepository.java
// └── presentation/     ← Depends on application; HTTP-specific code only
//     └── OrderController.java
 
// ── Domain layer: pure Java, zero framework annotations ──────────────────────
package com.example.order.domain;
 
import java.math.BigDecimal;
// => No import of Spring, JPA, or any framework — domain is framework-free
 
record OrderId(String value) {}
record Money(BigDecimal amount, String currency) {}
 
interface OrderRepository {
    void save(Order order);
    java.util.Optional<Order> findById(OrderId id);
    // => Interface defined in domain; implementation lives in infrastructure
}
 
class Order {
    private final OrderId id;
    private Money total;
    Order(OrderId id, Money total) { this.id = id; this.total = total; }
    public OrderId getId() { return id; }
}
 
// ── Application layer: orchestrates domain objects ────────────────────────────
package com.example.order.application;
 
// import com.example.order.domain.*;  ← Allowed: app depends on domain
// import org.springframework.stereotype.*;  ← Allowed: app may have framework annotations
 
class ConfirmOrderUseCase {
    private final OrderRepository repo; // => Domain interface; no JPA type here
    ConfirmOrderUseCase(OrderRepository repo) { this.repo = repo; }
 
    void execute(OrderId id, Money total) {
        var order = repo.findById(id).orElseThrow();
        // => Use domain objects; delegate to domain; no SQL here
    }
}
 
// ── Infrastructure layer: JPA, DB, messaging ─────────────────────────────────
// package com.example.order.infrastructure;
// class JpaOrderRepository implements OrderRepository { ... JPA code ... }
// => Implements domain interface; domain never imports JpaOrderRepository
 
// ── Presentation layer: HTTP only ────────────────────────────────────────────
// package com.example.order.presentation;
// class OrderController { ... REST endpoint, delegates to ConfirmOrderUseCase ... }

Kotlin:

// ── Kotlin package layout mirrors the same four layers ─────────────────────────
// com.example.order.domain       — entities, VOs, repository interfaces, events
// com.example.order.application  — use cases, DTOs, application services
// com.example.order.infrastructure — JPA / R2DBC / Kafka implementations
// com.example.order.presentation — Ktor / Spring MVC controllers
 
// ── Domain: no framework imports ──────────────────────────────────────────────
// package com.example.order.domain
 
import java.math.BigDecimal
 
data class OrderId(val value: String)
data class Money(val amount: BigDecimal, val currency: String)
 
interface OrderRepository {
    fun save(order: Order)
    fun findById(id: OrderId): Order?
    // => Interface only; Kotlin null-return instead of Optional
}
 
data class Order(val id: OrderId, val total: Money)
 
// ── Application: depends on domain, not infrastructure ────────────────────────
// package com.example.order.application
 
class ConfirmOrderUseCase(private val repo: OrderRepository) {
    // => repo is the domain interface — not the JPA class
    fun execute(id: OrderId): Order {
        return repo.findById(id) ?: error("Order not found: ${id.value}")
    }
}
 
// ── Infrastructure (conceptual): implements domain interfaces ─────────────────
// class ExposedOrderRepository(private val db: Database) : OrderRepository { ... }
 
// ── Presentation (conceptual): HTTP entry points only ─────────────────────────
// fun Route.orderRoutes(useCase: ConfirmOrderUseCase) { post("/orders/{id}/confirm") { ... } }

C#:

// ── .NET namespace layout mirrors layers ──────────────────────────────────────
// Example.Order.Domain         — entities, VOs, interfaces
// Example.Order.Application    — use cases, DTOs
// Example.Order.Infrastructure — EF Core, messaging
// Example.Order.Presentation   — ASP.NET controllers
 
// ── Domain: no EF Core, no ASP.NET ───────────────────────────────────────────
// namespace Example.Order.Domain
 
public record OrderId(string Value);
public record Money(decimal Amount, string Currency);
public record Order(OrderId Id, Money Total);
 
public interface IOrderRepository
{
    void Save(Order order);
    Order? FindById(OrderId id);
    // => Interface only; EF Core lives in Infrastructure
}
 
// ── Application: depends on domain interface ──────────────────────────────────
// namespace Example.Order.Application
 
public class ConfirmOrderUseCase(IOrderRepository repo)
{
    public Order Execute(OrderId id) =>
        repo.FindById(id) ?? throw new KeyNotFoundException($"Order {id.Value} not found");
    // => Uses domain interface; no concrete DB type imported here
}
 
// ── Infrastructure (conceptual) ───────────────────────────────────────────────
// namespace Example.Order.Infrastructure
// class EfOrderRepository(AppDbContext ctx) : IOrderRepository { ... }
 
// ── Presentation (conceptual) ────────────────────────────────────────────────
// namespace Example.Order.Presentation
// [ApiController] class OrderController(ConfirmOrderUseCase uc) { ... }

Key Takeaway: Four layers — Domain, Application, Infrastructure, Presentation — with dependencies pointing inward. The domain layer imports nothing; infrastructure implements domain interfaces.

Why It Matters: Layered architecture makes the most important code — domain logic — framework-free and directly unit-testable. When JPA annotations live in domain classes, you cannot test a domain invariant without starting a database. When domain defines only interfaces, infrastructure implementations can be swapped (PostgreSQL → MongoDB, Kafka → RabbitMQ) without touching domain or application code.


Example 44: Hexagonal architecture — port + adapter

Hexagonal architecture names the layer boundary a "port" (interface in the domain) and the implementation an "adapter" (infrastructure class). The domain drives through "driving ports" and is driven through "driven ports". This makes the domain testable with any adapter swapped in.

graph LR
    REST["REST Adapter<br/>(Driving)"]:::blue --> DP["Driving Port<br/>PlaceOrderUseCase"]:::orange
    DP --> D["Domain<br/>Order"]:::teal
    D --> DRIV["Driven Port<br/>OrderRepository"]:::orange
    DRIV --> DB["DB Adapter<br/>(Driven)"]:::purple
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px

Java:

import java.math.BigDecimal;                                          // => namespace/package import
import java.util.*;                                                   // => namespace/package import
 
// ── Domain types ──────────────────────────────────────────────────────────────
record OrderId(String value)    { static OrderId gen() { return new OrderId(UUID.randomUUID().toString()); } } // => record OrderId
record CustomerId(String value) {}                                    // => record CustomerId
record Money(BigDecimal amount, String currency) {}                   // => record Money
record Order(OrderId id, CustomerId customerId, Money total) {}       // => record Order
 
// ── DRIVING PORT: what the application offers to external callers ─────────────
// Lives in domain/application layer; no framework imports
interface PlaceOrderPort {                                            // => interface PlaceOrderPort
    OrderId placeOrder(CustomerId customerId, Money total);           // => expression
    // => Driving adapter (REST controller) calls this interface
}
 
// ── DRIVEN PORT: what the application needs from infrastructure ───────────────
// Interface defined in domain; implementations live in infrastructure
interface OrderRepository {                                           // => interface OrderRepository
    void save(Order order);                                           // => expression
    Optional<Order> findById(OrderId id);                             // => expression
}
 
// ── DOMAIN / APPLICATION: implements driving port, uses driven port ───────────
class PlaceOrderService implements PlaceOrderPort {                   // => class PlaceOrderService
    private final OrderRepository repo; // => Driven port injected
 
    PlaceOrderService(OrderRepository repo) { this.repo = repo; }     // => PlaceOrderService() called
 
    @Override                                                         // => expression
    public OrderId placeOrder(CustomerId customerId, Money total) {   // => placeOrder method
        OrderId id    = OrderId.gen();                                // => OrderId.gen() called
        Order   order = new Order(id, customerId, total);             // => expression
        repo.save(order); // => Uses driven port; no DB code here
        return id;        // => Returns ID to driving adapter
    }
}
 
// ── DRIVING ADAPTER: REST entry point (Infrastructure / Presentation) ─────────
// Translates HTTP request → drives the port → translates result → HTTP response
class OrderRestAdapter {                                              // => class OrderRestAdapter
    private final PlaceOrderPort port; // => Calls driving port; no domain internals
 
    OrderRestAdapter(PlaceOrderPort port) { this.port = port; }       // => OrderRestAdapter() called
 
    String handlePost(String customerId, double total, String currency) { // => expression
        // => Translate HTTP primitives to domain types
        OrderId id = port.placeOrder(                                 // => port.placeOrder() called
            new CustomerId(customerId),                               // => expression
            new Money(BigDecimal.valueOf(total), currency)            // => BigDecimal.valueOf() called
        );                                                            // => expression
        return "{\"orderId\": \"" + id.value() + "\"}"; // => HTTP response JSON
    }
}
 
// ── DRIVEN ADAPTER: in-memory repository (swappable for JPA) ─────────────────
class InMemoryOrderRepository implements OrderRepository {            // => class InMemoryOrderRepository
    private final Map<String, Order> store = new HashMap<>();         // => Map method
    @Override public void save(Order o) { store.put(o.id().value(), o); } // => store.put() called
    @Override public Optional<Order> findById(OrderId id) { return Optional.ofNullable(store.get(id.value())); } // => Optional.ofNullable() called
}
 
// ── Wire up ───────────────────────────────────────────────────────────────────
var repo    = new InMemoryOrderRepository();    // => Driven adapter
var service = new PlaceOrderService(repo);      // => Domain wired to driven adapter
var rest    = new OrderRestAdapter(service);    // => Driving adapter wired to driving port
 
String response = rest.handlePost("C1", 99.99, "USD");                // => rest.handlePost() called
// => {"orderId": "<uuid>"} — full hexagonal flow executed

Kotlin:

import java.math.BigDecimal; import java.util.UUID                    // => namespace/package import
 
data class OrderId(val value: String) { companion object { fun gen() = OrderId(UUID.randomUUID().toString()) } } // => class OrderId
data class CustomerId(val value: String)                              // => class CustomerId
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
data class Order(val id: OrderId, val customerId: CustomerId, val total: Money) // => class Order
 
// ── Ports (interfaces in domain/application) ──────────────────────────────────
interface PlaceOrderPort { fun placeOrder(customerId: CustomerId, total: Money): OrderId } // => interface PlaceOrderPort
interface OrderRepository { fun save(order: Order); fun findById(id: OrderId): Order? } // => interface OrderRepository
 
// ── Domain service implements driving port, uses driven port ──────────────────
class PlaceOrderService(private val repo: OrderRepository) : PlaceOrderPort { // => class PlaceOrderService
    override fun placeOrder(customerId: CustomerId, total: Money): OrderId { // => placeOrder method
        val id = OrderId.gen()                                        // => id initialised
        repo.save(Order(id, customerId, total)) // => Driven port
        return id                                                     // => returns id
    // => ends block
    }
}
 
// ── Adapters ──────────────────────────────────────────────────────────────────
class OrderRestAdapter(private val port: PlaceOrderPort) {            // => class OrderRestAdapter
    fun handlePost(customerId: String, amount: Double, currency: String): String { // => handlePost method
        val id = port.placeOrder(CustomerId(customerId), Money(BigDecimal.valueOf(amount), currency)) // => id initialised
        return """{"orderId":"${id.value}"}"""                        // => returns """{"orderId":"${id.value}"}""
    }
}
 
class InMemoryOrderRepository : OrderRepository {                     // => class InMemoryOrderRepository
    private val store = mutableMapOf<String, Order>()                 // => store declared
    override fun save(order: Order) { store[order.id.value] = order } // => save method
    override fun findById(id: OrderId) = store[id.value]              // => findById method
}
 
// ── Wire up ───────────────────────────────────────────────────────────────────
val repo    = InMemoryOrderRepository()                               // => repo initialised
val service = PlaceOrderService(repo)                                 // => service initialised
val rest    = OrderRestAdapter(service)                               // => rest initialised
 
val response = rest.handlePost("C1", 99.99, "USD") // => {"orderId":"<uuid>"}

C#:

using System; using System.Collections.Generic;                       // => namespace/package import
 
public record OrderId(string Value) { public static OrderId Gen() => new(Guid.NewGuid().ToString()); } // => record OrderId
public record CustomerId(string Value);                               // => record CustomerId
public record Money(decimal Amount, string Currency);                 // => record Money
public record Order(OrderId Id, CustomerId CustomerId, Money Total);  // => record Order
 
// ── Ports ─────────────────────────────────────────────────────────────────────
public interface IPlaceOrderPort  { OrderId PlaceOrder(CustomerId customerId, Money total); } // => IPlaceOrderPort field
public interface IOrderRepository { void Save(Order o); Order? FindById(OrderId id); } // => IOrderRepository field
 
// ── Application service ───────────────────────────────────────────────────────
public class PlaceOrderService(IOrderRepository repo) : IPlaceOrderPort // => class PlaceOrderService
// => begins block
{
    public OrderId PlaceOrder(CustomerId customerId, Money total)     // => PlaceOrder method
    // => begins block
    {
        var id    = OrderId.Gen();                                    // => id initialised
        repo.Save(new Order(id, customerId, total)); // => Uses driven port
        return id;                                                    // => returns id
    // => ends block
    }
// => ends block
}
 
// ── Adapters ──────────────────────────────────────────────────────────────────
public class OrderRestAdapter(IPlaceOrderPort port)                   // => class OrderRestAdapter
// => begins block
{
    public string HandlePost(string customerId, decimal amount, string currency) // => HandlePost method
    // => begins block
    {
        var id = port.PlaceOrder(new CustomerId(customerId), new Money(amount, currency)); // => id initialised
        return $"{{\"orderId\":\"{id.Value}\"}}";                     // => returns $"{{\"orderId\":\"{id.Value}\"
    }
}
 
public class InMemoryOrderRepository : IOrderRepository               // => class InMemoryOrderRepository
{
    private readonly Dictionary<string, Order> _store = new();        // => Dictionary method
    public void   Save(Order o)          => _store[o.Id.Value] = o;   // => Save method
    public Order? FindById(OrderId id)   => _store.GetValueOrDefault(id.Value); // => method declaration
}
 
// ── Wire up ───────────────────────────────────────────────────────────────────
var repo    = new InMemoryOrderRepository();                          // => repo initialised
var service = new PlaceOrderService(repo);                            // => service initialised
var rest    = new OrderRestAdapter(service);                          // => rest initialised
 
var response = rest.HandlePost("C1", 99.99m, "USD"); // => {"orderId":"<guid>"}

Key Takeaway: Ports are interfaces owned by the domain; adapters are implementations owned by infrastructure. Swapping an adapter (in-memory → PostgreSQL) requires no domain change.

Why It Matters: Hexagonal architecture makes the domain 100% testable without databases, HTTP servers, or message brokers. Every external system is hidden behind a port. When a new messaging system is adopted, only the adapter changes — not a single line of domain logic. This is the structural foundation that makes DDD applications independently testable and infrastructure-agnostic.


Example 45: Dependency inversion at the domain boundary

The Dependency Inversion Principle states high-level modules (domain) must not depend on low-level modules (infrastructure). Both depend on abstractions (interfaces). In DDD, the domain defines the interface; infrastructure implements it.

Java:

import java.util.*;                                                   // => namespace/package import
 
// ── Without DIP (wrong): domain imports infrastructure ───────────────────────
// class OrderService {
//     private final JpaOrderRepository repo;  // ← domain depends on JPA — WRONG
// }
 
// ── With DIP (correct) ────────────────────────────────────────────────────────
// STEP 1: Domain defines the abstraction it needs
// => Interface lives in domain package; no infrastructure type imported
record OrderId(String value) {}                                       // => record OrderId
record Order(OrderId id, String status) {}                            // => record Order
 
interface OrderRepository {          // ← Domain-owned interface (abstraction) // => interface OrderRepository
    Optional<Order> findById(OrderId id);                             // => expression
    void save(Order order);                                           // => expression
}
 
// STEP 2: Infrastructure implements the domain interface
// => Infrastructure depends on domain (through the interface) — correct direction
class InMemoryOrderRepository implements OrderRepository {            // => class InMemoryOrderRepository
    private final Map<String, Order> store = new HashMap<>();         // => Map method
    @Override public Optional<Order> findById(OrderId id) { return Optional.ofNullable(store.get(id.value())); } // => Optional.ofNullable() called
    @Override public void save(Order o) { store.put(o.id().value(), o); } // => store.put() called
}
 
// STEP 3: Domain/Application depends only on the interface
class OrderApplicationService {                                       // => class OrderApplicationService
    private final OrderRepository repo; // ← Domain interface type; no JPA import // => repo field
 
    OrderApplicationService(OrderRepository repo) {                   // => OrderApplicationService() called
        this.repo = repo; // ← Injected at construction; swappable    // => this.repo assigned
    }
 
    Order getOrder(OrderId id) {                                      // => expression
        return repo.findById(id).orElseThrow(() -> new NoSuchElementException("Order not found: " + id)); // => returns repo.findById(id).orElseThrow(
    }
}
 
// ── Wire up (composition root) ────────────────────────────────────────────────
OrderRepository         repo    = new InMemoryOrderRepository(); // ← concrete type here only // => expression
OrderApplicationService service = new OrderApplicationService(repo);  // => expression
 
var saved = new Order(new OrderId("O1"), "PENDING");                  // => saved initialised
repo.save(saved);                                                     // => repo.save() called
 
Order fetched = service.getOrder(new OrderId("O1")); // => Order[id=O1, status=PENDING]

Kotlin:

// DIP in Kotlin: interface in domain module, implementation in infrastructure module
 
interface OrderRepository {                                           // => interface OrderRepository
    fun findById(id: String): String?  // => Simplified: returns status string
    fun save(id: String, status: String)                              // => save method
}
 
// Infrastructure: implements the domain-owned interface
class InMemoryOrderRepository : OrderRepository {                     // => class InMemoryOrderRepository
    private val store = mutableMapOf<String, String>()                // => store declared
    override fun findById(id: String) = store[id]                     // => findById method
    override fun save(id: String, status: String) { store[id] = status } // => save method
}
 
// Application service: depends only on interface (DIP satisfied)
class OrderService(private val repo: OrderRepository) {               // => class OrderService
    fun getStatus(id: String): String =                               // => getStatus method
        repo.findById(id) ?: error("Order not found: $id")            // => repo.findById() called
    // => No import of InMemoryOrderRepository; no knowledge of implementation
}
 
// ── Composition root ──────────────────────────────────────────────────────────
val repo    = InMemoryOrderRepository()   // => Concrete type only at wiring point
val service = OrderService(repo)          // => Service sees only OrderRepository interface
 
repo.save("O1", "PENDING")                                            // => repo.save() called
val status = service.getStatus("O1") // => "PENDING"

C#:

using System; using System.Collections.Generic;                       // => namespace/package import
 
// DIP: IOrderRepository defined in domain; implemented in infrastructure
public interface IOrderRepository                                     // => interface IOrderRepository
// => begins block
{
    string? FindStatus(string id);                                    // => expression
    void    Save(string id, string status);                           // => expression
// => ends block
}
 
public class InMemoryOrderRepository : IOrderRepository               // => class InMemoryOrderRepository
// => begins block
{
    private readonly Dictionary<string, string> _store = new();       // => Dictionary method
    public string? FindStatus(string id)       => _store.GetValueOrDefault(id); // => method declaration
    public void    Save(string id, string s)   => _store[id] = s;     // => Save method
}
 
// Application service depends on interface — not on InMemoryOrderRepository
public class OrderService(IOrderRepository repo)                      // => class OrderService
{
    public string GetStatus(string id) =>                             // => GetStatus method
        repo.FindStatus(id) ?? throw new KeyNotFoundException($"Order not found: {id}"); // => throws if guard fails
}
 
// ── Composition root (e.g., Program.cs or IoC container) ─────────────────────
IOrderRepository repo    = new InMemoryOrderRepository(); // => Concrete here only
var              service = new OrderService(repo);                    // => service initialised
 
repo.Save("O1", "Pending");                                           // => repo.Save() called
string status = service.GetStatus("O1"); // => "Pending"

Key Takeaway: The domain defines what it needs (interface); infrastructure provides it (implementation). The domain never imports the concrete infrastructure class.

Why It Matters: When domain code imports JpaOrderRepository, a unit test of the domain must start a JPA context. With DIP, the unit test injects new InMemoryOrderRepository() and never touches a database. DIP is also the structural requirement for the Hexagonal / Ports and Adapters architecture: without it, adapters cannot be swapped without domain changes.


Example 46: CQRS — separating commands from queries

CQRS (Command Query Responsibility Segregation) splits the model into a write side (Commands: change state, return nothing or an ID) and a read side (Queries: return data, change nothing). Each side can be optimised independently.

graph TD
    C["Client"]:::blue
    C -->|PlaceOrderCommand| CH["CommandHandler<br/>writes Order"]:::orange
    CH --> WS[("Write Store<br/>Aggregates")]:::teal
    C -->|GetOrderQuery| QH["QueryHandler<br/>reads projection"]:::purple
    QH --> RS[("Read Store<br/>Projections")]:::purple
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px

Java:

import java.math.BigDecimal;                                          // => namespace/package import
import java.util.*;                                                   // => namespace/package import
 
// ── Domain types ──────────────────────────────────────────────────────────────
record OrderId(String value)    { static OrderId gen() { return new OrderId(UUID.randomUUID().toString()); } } // => record OrderId
record CustomerId(String value) {}                                    // => record CustomerId
record Money(BigDecimal amount, String currency) {}                   // => record Money
 
// ── WRITE SIDE ────────────────────────────────────────────────────────────────
// Command: intent to change state; no return data payload
record PlaceOrderCommand(CustomerId customerId, Money total) {}       // => record PlaceOrderCommand
 
class Order {                                                         // => class Order
    final OrderId    id;                                              // => expression
    final CustomerId customerId;                                      // => expression
    final Money      total;                                           // => expression
    String status = "PENDING";                                        // => expression
    Order(OrderId id, CustomerId cid, Money total) { this.id=id; this.customerId=cid; this.total=total; } // => Order() called
}
 
// Command handler: mutates write store; returns only ID
class PlaceOrderCommandHandler {                                      // => class PlaceOrderCommandHandler
    private final Map<String, Order> writeStore = new HashMap<>(); // => Write model
 
    public OrderId handle(PlaceOrderCommand cmd) {                    // => handle method
        OrderId id    = OrderId.gen();                                // => OrderId.gen() called
        Order   order = new Order(id, cmd.customerId(), cmd.total()); // => cmd.customerId() called
        writeStore.put(id.value(), order);  // => Persist aggregate
        return id;                          // => Return ID only; no read data
    }
}
 
// ── READ SIDE ─────────────────────────────────────────────────────────────────
// Query: request for data; must NOT change state
record GetOrderQuery(OrderId orderId) {}                              // => record GetOrderQuery
 
// Read model (projection): flat DTO optimised for display — no domain logic
record OrderReadModel(String orderId, String customerName, String amount, String status) {} // => record OrderReadModel
 
// Query handler: reads from read store; never writes
class GetOrderQueryHandler {                                          // => class GetOrderQueryHandler
    private final Map<String, OrderReadModel> readStore = new HashMap<>(); // => Read model
 
    public void project(Order order) {                                // => project method
        // => Projection: translate aggregate to read model (called after write)
        readStore.put(order.id.value(),                               // => readStore.put() called
            new OrderReadModel(order.id.value(), order.customerId.value(), // => id.value() called
                               order.total.amount().toPlainString() + " " + order.total.currency(), // => total.amount() called
                               order.status));                        // => expression
    }
 
    public Optional<OrderReadModel> handle(GetOrderQuery query) {     // => handle method
        return Optional.ofNullable(readStore.get(query.orderId().value())); // => returns Optional.ofNullable(readStore.
        // => Read-only; no state change here
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var cmdHandler = new PlaceOrderCommandHandler();                      // => cmdHandler initialised
var qryHandler = new GetOrderQueryHandler();                          // => qryHandler initialised
 
var cmd = new PlaceOrderCommand(new CustomerId("Alice"), new Money(new BigDecimal("75"), "USD")); // => cmd initialised
OrderId id = cmdHandler.handle(cmd); // => Write side: order created
 
// After command completes, project to read model (synchronous here; async in production)
var order = cmdHandler.writeStore.get(id.value()); // => Simplification for demo
qryHandler.project(order);                                            // => qryHandler.project() called
 
Optional<OrderReadModel> result = qryHandler.handle(new GetOrderQuery(id)); // => qryHandler.handle() called
result.ifPresent(rm -> System.out.println(rm.amount())); // => 75 USD

Kotlin:

import java.math.BigDecimal; import java.util.UUID                    // => namespace/package import
 
data class OrderId(val value: String) { companion object { fun gen() = OrderId(UUID.randomUUID().toString()) } } // => class OrderId
data class CustomerId(val value: String)                              // => class CustomerId
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
 
// ── Commands ──────────────────────────────────────────────────────────────────
data class PlaceOrderCommand(val customerId: CustomerId, val total: Money) // => class PlaceOrderCommand
 
class Order(val id: OrderId, val customerId: CustomerId, val total: Money, var status: String = "PENDING") // => class Order
 
class PlaceOrderCommandHandler {                                      // => class PlaceOrderCommandHandler
    val writeStore = mutableMapOf<String, Order>()                    // => writeStore initialised
    fun handle(cmd: PlaceOrderCommand): OrderId {                     // => handle method
        val id = OrderId.gen()                                        // => id initialised
        writeStore[id.value] = Order(id, cmd.customerId, cmd.total)   // => expression
        return id // => Only ID returned; no data returned from command
    // => ends block
    }
// => ends block
}
 
// ── Queries ───────────────────────────────────────────────────────────────────
data class GetOrderQuery(val orderId: OrderId)                        // => class GetOrderQuery
data class OrderReadModel(val orderId: String, val customerName: String, val amount: String, val status: String) // => class OrderReadModel
 
class GetOrderQueryHandler {                                          // => class GetOrderQueryHandler
    private val readStore = mutableMapOf<String, OrderReadModel>()    // => readStore declared
 
    fun project(order: Order) {                                       // => project method
        readStore[order.id.value] = OrderReadModel(                   // => expression
            order.id.value, order.customerId.value,                   // => expression
            "${order.total.amount} ${order.total.currency}", order.status // => expression
        )
    }
 
    fun handle(query: GetOrderQuery): OrderReadModel? =               // => handle method
        readStore[query.orderId.value] // => Read-only; returns null if not projected yet
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val cmdH = PlaceOrderCommandHandler()                                 // => cmdH initialised
val qryH = GetOrderQueryHandler()                                     // => qryH initialised
 
val id    = cmdH.handle(PlaceOrderCommand(CustomerId("Alice"), Money(BigDecimal("75"), "USD"))) // => id initialised
val order = cmdH.writeStore[id.value]!!                               // => order initialised
qryH.project(order)                                                   // => qryH.project() called
 
val result = qryH.handle(GetOrderQuery(id)) // => OrderReadModel(amount="75 USD", ...)
println(result?.amount) // => 75 USD

C#:

using System; using System.Collections.Generic;                       // => namespace/package import
 
public record OrderId(string Value)      { public static OrderId Gen() => new(Guid.NewGuid().ToString()); } // => record OrderId
public record CustomerId(string Value);                               // => record CustomerId
public record Money(decimal Amount, string Currency);                 // => record Money
 
// ── Commands ──────────────────────────────────────────────────────────────────
public record PlaceOrderCommand(CustomerId CustomerId, Money Total);  // => record PlaceOrderCommand
 
public class Order(OrderId id, CustomerId cid, Money total)           // => class Order
// => begins block
{
    public OrderId    Id         { get; } = id;                       // => Id field
    public CustomerId CustomerId { get; } = cid;                      // => CustomerId field
    public Money      Total      { get; } = total;                    // => Total field
    public string     Status     { get; set; } = "Pending";           // => Status field
// => ends block
}
 
public class PlaceOrderCommandHandler                                 // => class PlaceOrderCommandHandler
// => begins block
{
    public readonly Dictionary<string, Order> WriteStore = new();     // => Dictionary method
    public OrderId Handle(PlaceOrderCommand cmd)                      // => Handle method
    // => begins block
    {
        var id = OrderId.Gen();                                       // => id initialised
        WriteStore[id.Value] = new Order(id, cmd.CustomerId, cmd.Total); // => expression
        return id; // => Command returns ID; no read data
    // => ends block
    }
}
 
// ── Queries ───────────────────────────────────────────────────────────────────
public record GetOrderQuery(OrderId OrderId);                         // => record GetOrderQuery
public record OrderReadModel(string OrderId, string CustomerName, string Amount, string Status); // => record OrderReadModel
 
public class GetOrderQueryHandler                                     // => class GetOrderQueryHandler
{
    private readonly Dictionary<string, OrderReadModel> _readStore = new(); // => Dictionary method
 
    public void Project(Order order) =>                               // => Project method
        _readStore[order.Id.Value] = new OrderReadModel(              // => expression
            order.Id.Value, order.CustomerId.Value,                   // => expression
            $"{order.Total.Amount} {order.Total.Currency}", order.Status); // => expression
 
    public OrderReadModel? Handle(GetOrderQuery q) =>                 // => method declaration
        _readStore.GetValueOrDefault(q.OrderId.Value);                // => _readStore.GetValueOrDefault() called
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var cmdH = new PlaceOrderCommandHandler();                            // => cmdH initialised
var qryH = new GetOrderQueryHandler();                                // => qryH initialised
 
var id    = cmdH.Handle(new PlaceOrderCommand(new CustomerId("Alice"), new Money(75m, "USD"))); // => id initialised
qryH.Project(cmdH.WriteStore[id.Value]);                              // => qryH.Project() called
 
var result = qryH.Handle(new GetOrderQuery(id)); // => OrderReadModel(Amount="75 USD")
Console.WriteLine(result?.Amount); // => 75 USD

Key Takeaway: Commands change state and return nothing but confirmation; queries return data and change nothing. Separating them allows each side to be modelled, optimised, and scaled independently.

Why It Matters: A single model serving both reads and writes is often a poor fit for both. Write models optimise for invariant enforcement; read models optimise for query performance. CQRS lets you use a rich domain aggregate on the write side and a denormalised, indexed view on the read side — each optimal for its purpose. At scale, the two sides can use different databases entirely.


Application Layer Patterns (Examples 47-50)

Example 47: Read model / projection

A projection transforms domain aggregate state into a flat read model optimised for a specific query. Projections are rebuilt from Domain Events, allowing the read store to diverge from the write model without affecting domain logic.

Java:

import java.math.BigDecimal;                                          // => namespace/package import
import java.time.Instant;                                             // => namespace/package import
import java.util.*;                                                   // => namespace/package import
 
// ── Domain event ──────────────────────────────────────────────────────────────
interface DomainEvent {}                                              // => interface DomainEvent
record OrderId(String value) {}                                       // => record OrderId
record CustomerId(String value) {}                                    // => record CustomerId
record Money(BigDecimal amount, String currency) {}                   // => record Money
record OrderPlaced(OrderId orderId, CustomerId customerId, Money total, Instant at) implements DomainEvent {} // => record OrderPlaced
record OrderShipped(OrderId orderId, Instant at) implements DomainEvent {} // => record OrderShipped
 
// ── Read model: flat, denormalised, query-optimised ───────────────────────────
// No domain logic here; pure data for display
class OrderSummaryReadModel {                                         // => class OrderSummaryReadModel
    public final String orderId;                                      // => orderId field
    public final String customerId;                                   // => customerId field
    public final String total;                                        // => total field
    public       String status; // => Mutable: updated by subsequent events
 
    OrderSummaryReadModel(String orderId, String customerId, String total, String status) { // => OrderSummaryReadModel() called
        this.orderId    = orderId;                                    // => this.orderId assigned
        this.customerId = customerId;                                 // => this.customerId assigned
        this.total      = total;                                      // => this.total assigned
        this.status     = status;                                     // => this.status assigned
    }
}
 
// ── Projection: listens to events, builds read model ─────────────────────────
class OrderProjection {                                               // => class OrderProjection
    private final Map<String, OrderSummaryReadModel> store = new HashMap<>(); // => Map method
    // => Read store: keyed by orderId; separate from aggregate write store
 
    // Each handler updates or creates a read model entry
    public void on(OrderPlaced event) {                               // => on method
        store.put(event.orderId().value(),                            // => store.put() called
            new OrderSummaryReadModel(                                // => expression
                event.orderId().value(),                              // => event.orderId() called
                event.customerId().value(),                           // => event.customerId() called
                event.total().amount().toPlainString() + " " + event.total().currency(), // => event.total() called
                "PLACED"                                              // => expression
            ));                                                       // => expression
        // => Read model created when order is placed
    }
 
    public void on(OrderShipped event) {                              // => on method
        OrderSummaryReadModel rm = store.get(event.orderId().value()); // => store.get() called
        if (rm != null) rm.status = "SHIPPED"; // => Only status updated; total unchanged
        // => Projection updates on each relevant event
    }
 
    public Optional<OrderSummaryReadModel> findById(String orderId) { // => findById method
        return Optional.ofNullable(store.get(orderId)); // => Fast read; no aggregate loading
    }
 
    public List<OrderSummaryReadModel> findAll() {                    // => findAll method
        return List.copyOf(store.values()); // => All read models; defensive copy
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var projection = new OrderProjection();                               // => projection initialised
 
// Replay events to build read model
projection.on(new OrderPlaced(                                        // => projection.on() called
    new OrderId("O1"), new CustomerId("C1"),                          // => expression
    new Money(new BigDecimal("99"), "USD"), Instant.now()));          // => Instant.now() called
 
var rm = projection.findById("O1").get();                             // => rm initialised
System.out.println(rm.status); // => PLACED
 
projection.on(new OrderShipped(new OrderId("O1"), Instant.now()));    // => projection.on() called
System.out.println(rm.status); // => SHIPPED (same object, status updated)

Kotlin:

import java.math.BigDecimal; import java.time.Instant                 // => namespace/package import
 
interface DomainEvent                                                 // => interface DomainEvent
data class OrderId(val value: String); data class CustomerId(val value: String) // => class OrderId
data class Money(val amount: BigDecimal, val currency: String)        // => class Money
data class OrderPlaced(val orderId: OrderId, val customerId: CustomerId, val total: Money, val at: Instant) : DomainEvent // => class OrderPlaced
data class OrderShipped(val orderId: OrderId, val at: Instant) : DomainEvent // => class OrderShipped
 
// Read model: mutable for incremental updates from events
data class OrderSummaryReadModel(                                     // => class OrderSummaryReadModel
    val orderId:    String,                                           // => expression
    val customerId: String,                                           // => expression
    val total:      String,                                           // => expression
    var status:     String  // => var: status changes as events arrive
)
 
class OrderProjection {                                               // => class OrderProjection
    private val store = mutableMapOf<String, OrderSummaryReadModel>() // => store declared
 
    fun on(event: OrderPlaced) {                                      // => on method
        store[event.orderId.value] = OrderSummaryReadModel(           // => expression
            event.orderId.value, event.customerId.value,              // => expression
            "${event.total.amount} ${event.total.currency}", "PLACED" // => expression
        ) // => New read model entry for this order
    // => ends block
    }
 
    fun on(event: OrderShipped) {                                     // => on method
        store[event.orderId.value]?.status = "SHIPPED"                // => expression
        // => Only update status; all other fields remain from OrderPlaced
    }
 
    fun findById(id: String) = store[id]                              // => findById method
    fun findAll()            = store.values.toList()                  // => findAll method
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val proj = OrderProjection()                                          // => proj initialised
proj.on(OrderPlaced(OrderId("O1"), CustomerId("C1"), Money(BigDecimal("99"), "USD"), Instant.now())) // => proj.on() called
println(proj.findById("O1")?.status) // => PLACED
 
proj.on(OrderShipped(OrderId("O1"), Instant.now()))                   // => proj.on() called
println(proj.findById("O1")?.status) // => SHIPPED

C#:

using System; using System.Collections.Generic;                       // => namespace/package import
 
public interface IDomainEvent {}                                      // => IDomainEvent field
public record OrderId(string Value); public record CustomerId(string Value); // => record OrderId
public record Money(decimal Amount, string Currency);                 // => record Money
public record OrderPlaced(OrderId OrderId, CustomerId CustomerId, Money Total, DateTimeOffset At) : IDomainEvent; // => record OrderPlaced
public record OrderShipped(OrderId OrderId, DateTimeOffset At) : IDomainEvent; // => record OrderShipped
 
public class OrderSummaryReadModel                                    // => class OrderSummaryReadModel
// => begins block
{
    public string OrderId    { get; init; } = "";                     // => OrderId field
    public string CustomerId { get; init; } = "";                     // => CustomerId field
    public string Total      { get; init; } = "";                     // => Total field
    public string Status     { get; set;  } = ""; // => set: updated by subsequent events
// => ends block
}
 
public class OrderProjection                                          // => class OrderProjection
// => begins block
{
    private readonly Dictionary<string, OrderSummaryReadModel> _store = new(); // => Dictionary method
 
    public void On(OrderPlaced evt) =>                                // => On method
        _store[evt.OrderId.Value] = new OrderSummaryReadModel         // => expression
        // => begins block
        {
            OrderId    = evt.OrderId.Value,                           // => OrderId assigned
            CustomerId = evt.CustomerId.Value,                        // => CustomerId assigned
            Total      = $"{evt.Total.Amount} {evt.Total.Currency}",  // => Total assigned
            Status     = "Placed"                                     // => Status assigned
        };
 
    public void On(OrderShipped evt)                                  // => On method
    // => begins block
    {
        if (_store.TryGetValue(evt.OrderId.Value, out var rm))        // => precondition check
            rm.Status = "Shipped"; // => Only status changes; other fields unchanged
    // => ends block
    }
 
    public OrderSummaryReadModel? FindById(string id) => _store.GetValueOrDefault(id); // => method declaration
// => ends block
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var proj = new OrderProjection();                                     // => proj initialised
proj.On(new OrderPlaced(new OrderId("O1"), new CustomerId("C1"),      // => proj.On() called
        new Money(99m, "USD"), DateTimeOffset.UtcNow));               // => expression
Console.WriteLine(proj.FindById("O1")?.Status); // => Placed
 
proj.On(new OrderShipped(new OrderId("O1"), DateTimeOffset.UtcNow));  // => proj.On() called
Console.WriteLine(proj.FindById("O1")?.Status); // => Shipped

Key Takeaway: Projections transform Domain Events into flat read models. They are rebuilt from events, making read and write models independently evolvable.

Why It Matters: Projections decouple the display model from the domain model. The aggregate optimises for invariant enforcement; the projection optimises for query speed. When a new reporting view is needed, a new projection is added without touching the domain. Replaying events rebuilds any projection from scratch — a powerful consistency guarantee unavailable when read and write share a single table.


Example 48: Command DTO at the application input

A Command DTO is a plain data class carrying input from the presentation layer to the application layer. It has no domain logic — it is a transport object that the application service validates and maps to domain types.

Java:

import java.math.BigDecimal;
 
// ── Command DTO: raw input from HTTP / message queue ─────────────────────────
// Plain data; may have null fields if validation has not yet run
// => DTO = Data Transfer Object; crosses the presentation→application boundary
public record PlaceOrderRequest(
    String customerId,  // => Raw string from JSON; not yet a CustomerId VO
    double total,       // => Primitive; not yet wrapped in a Money VO
    String currency     // => Raw string; not yet validated against ISO 4217
) {}
// => No domain logic here; no invariant check; just transport data
// => Deliberately uses primitives: the presentation layer speaks JSON, not domain types
 
// ── Domain types ──────────────────────────────────────────────────────────────
// These enforce invariants; they are NOT created by the DTO directly
record OrderId(String value)    { static OrderId gen() { return new OrderId(java.util.UUID.randomUUID().toString()); } }
// => OrderId wraps a UUID string; gen() creates a fresh identity
record CustomerId(String value) {}
// => Typed ID: prevents mixing with other ID types at compile time
record Money(BigDecimal amount, String currency) {
    Money { if (amount.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("Negative"); }
    // => Compact constructor: amount validated before assignment; negative Money cannot exist
}
 
// ── Application service: validates input, maps to domain types ────────────────
class PlaceOrderApplicationService {
    public OrderId handle(PlaceOrderRequest request) {
        // ── Input validation (application layer concern) ──────────────────────
        // Validate raw input before constructing domain types
        if (request.customerId() == null || request.customerId().isBlank())
            throw new IllegalArgumentException("customerId required");
            // => Fail early: no point constructing Money if customerId is missing
        if (request.total() <= 0)
            throw new IllegalArgumentException("total must be positive");
            // => Business rule: zero or negative totals are not valid orders
        if (request.currency() == null || request.currency().length() != 3)
            throw new IllegalArgumentException("currency must be 3-letter ISO code");
            // => Format check: ensures currency is parseable as ISO 4217
 
        // ── Map to domain types (domain layer boundary) ───────────────────────
        // Only after validation passes do we construct strongly-typed domain objects
        CustomerId customerId = new CustomerId(request.customerId());
        // => CustomerId now wraps the validated raw string
        Money total = new Money(BigDecimal.valueOf(request.total()), request.currency());
        // => Money's compact constructor provides its own invariant check (amount >= 0)
        // => BigDecimal.valueOf preserves precision better than new BigDecimal(double)
 
        // ── Execute domain operation ──────────────────────────────────────────
        OrderId id = OrderId.gen();
        // => In real app: pass customerId and total to an aggregate factory
        System.out.println("Placed order " + id.value() + " for " + customerId + ": " + total);
        // => Log confirms the domain types were constructed correctly
        return id; // => Return the new aggregate's identity to the presentation layer
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var service = new PlaceOrderApplicationService();
OrderId id  = service.handle(new PlaceOrderRequest("C1", 99.99, "USD"));
// => Placed order <uuid> for CustomerId[value=C1]: Money[amount=99.99, currency=USD]
// => id carries the UUID of the newly created order

Kotlin:

import java.math.BigDecimal; import java.util.UUID                    // => namespace/package import
 
// Command DTO: carries raw HTTP/message-queue input
data class PlaceOrderRequest(                                         // => class PlaceOrderRequest
    val customerId: String,                                           // => expression
    val total:      Double,                                           // => expression
    val currency:   String                                            // => expression
)
// => No require() here; validation is the application service's job
 
data class OrderId(val value: String) { companion object { fun gen() = OrderId(UUID.randomUUID().toString()) } } // => class OrderId
data class CustomerId(val value: String)                              // => class CustomerId
data class Money(val amount: BigDecimal, val currency: String) {      // => class Money
    init { require(amount >= BigDecimal.ZERO) { "Negative total" } }  // => expression
}
 
class PlaceOrderApplicationService {                                  // => class PlaceOrderApplicationService
    fun handle(request: PlaceOrderRequest): OrderId {                 // => handle method
        // ── Validate raw input ────────────────────────────────────────────────
        require(request.customerId.isNotBlank()) { "customerId required" } // => precondition check
        require(request.total > 0)               { "total must be positive" } // => precondition check
        require(request.currency.length == 3)    { "currency must be 3-char ISO code" } // => precondition check
 
        // ── Map to domain types ───────────────────────────────────────────────
        val customerId = CustomerId(request.customerId)               // => customerId initialised
        val total      = Money(BigDecimal.valueOf(request.total), request.currency) // => total initialised
        // => Domain VOs validate their own sub-invariants
 
        val id = OrderId.gen()                                        // => id initialised
        println("Placed order ${id.value} for $customerId: $total")   // => output to console
        return id                                                     // => returns id
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val id = PlaceOrderApplicationService().handle(PlaceOrderRequest("C1", 99.99, "USD")) // => id initialised
// => Placed order <uuid> for CustomerId(value=C1): Money(amount=99.99, currency=USD)

C#:

using System;                                                         // => namespace/package import
 
// Command DTO: primitive types from HTTP deserialization
public record PlaceOrderRequest(string CustomerId, decimal Total, string Currency); // => record PlaceOrderRequest
// => Nullable fields and primitives; not domain types yet
 
public record OrderId(string Value) { public static OrderId Gen() => new(Guid.NewGuid().ToString()); } // => record OrderId
public record CustomerId(string Value);                               // => record CustomerId
public record Money(decimal Amount, string Currency)                  // => record Money
{
    public Money : this(Amount, Currency) { if (Amount < 0) throw new ArgumentException("Negative"); } // => throws if guard fails
}
 
public class PlaceOrderApplicationService                             // => class PlaceOrderApplicationService
{
    public OrderId Handle(PlaceOrderRequest request)                  // => Handle method
    {
        // ── Input validation ──────────────────────────────────────────────────
        if (string.IsNullOrWhiteSpace(request.CustomerId)) throw new ArgumentException("customerId required"); // => throws if guard fails
        if (request.Total <= 0)                            throw new ArgumentException("total must be positive"); // => throws if guard fails
        if (request.Currency?.Length != 3)                 throw new ArgumentException("currency must be 3 chars"); // => throws if guard fails
 
        // ── Map to domain types ───────────────────────────────────────────────
        var customerId = new CustomerId(request.CustomerId);          // => customerId initialised
        var total      = new Money(request.Total, request.Currency!); // => total initialised
 
        var id = OrderId.Gen();                                       // => id initialised
        Console.WriteLine($"Placed order {id.Value} for {customerId}: {total}"); // => output to console
        return id;                                                    // => returns id
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var id = new PlaceOrderApplicationService().Handle(new PlaceOrderRequest("C1", 99.99m, "USD")); // => id initialised
// => Placed order <guid> for CustomerId { Value = C1 }: Money { Amount = 99.99, Currency = USD }

Key Takeaway: Command DTOs carry raw input across the presentation-to-application boundary. The application service validates and maps them to domain types before any domain operation runs.

Why It Matters: Mixing domain types and transport types creates tight coupling between the presentation protocol and the domain model. When the API changes its field names, you change the domain type — the wrong layer. Command DTOs act as an anti-corruption layer: the presentation layer speaks HTTP primitives; the domain speaks Value Objects. Each layer changes for its own reasons.


Example 49: Query DTO at the application output

A Query DTO carries data from the application layer back to the presentation layer. It is optimised for display, not for domain logic. Returning domain aggregates from queries leaks domain internals to callers.

Java:

import java.util.*;                                                   // => namespace/package import
 
// ── Domain types (internal) ───────────────────────────────────────────────────
record OrderId(String value) {}                                       // => record OrderId
record CustomerId(String value) {}                                    // => record CustomerId
 
class Order {                                                         // => class Order
    private final OrderId    id;                                      // => id field
    private final CustomerId customerId;                              // => customerId field
    private String status = "PENDING";                                // => status declared
    private int    itemCount;                                         // => itemCount field
 
    Order(OrderId id, CustomerId cid, int itemCount) {                // => Order() called
        this.id = id; this.customerId = cid; this.itemCount = itemCount; // => this.id assigned
    }
    OrderId    getId()          { return id; }                        // => expression
    CustomerId getCustomerId()  { return customerId; }                // => expression
    String     getStatus()      { return status; }                    // => expression
    int        getItemCount()   { return itemCount; }                 // => expression
}
 
// ── Query DTO: display-optimised response shape ───────────────────────────────
// Flat, serialisation-friendly; no domain methods or invariants
public record OrderSummaryDto(                                        // => record OrderSummaryDto
    String orderId,      // => Flat string, not OrderId VO
    String customerId,   // => Flat string, not CustomerId VO
    String status,       // => Display string
    int    itemCount                                                  // => expression
) {}                                                                  // => expression
 
// ── Query handler: maps domain object to DTO ──────────────────────────────────
class GetOrderSummaryHandler {                                        // => class GetOrderSummaryHandler
    private final Map<String, Order> store;                           // => expression
 
    GetOrderSummaryHandler(Map<String, Order> store) { this.store = store; } // => GetOrderSummaryHandler() called
 
    public Optional<OrderSummaryDto> handle(String orderId) {         // => handle method
        return Optional.ofNullable(store.get(orderId))                // => returns Optional.ofNullable(store.get(
            .map(order -> new OrderSummaryDto(                        // => expression
                order.getId().value(),                                // => order.getId() called
                order.getCustomerId().value(),                        // => order.getCustomerId() called
                order.getStatus(),                                    // => order.getStatus() called
                order.getItemCount()                                  // => order.getItemCount() called
            ));                                                       // => expression
        // => Map: aggregate → DTO; caller never sees domain internals
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var store   = new HashMap<String, Order>();                           // => store initialised
var order   = new Order(new OrderId("O1"), new CustomerId("C1"), 3);  // => order initialised
store.put("O1", order);                                               // => store.put() called
 
var handler = new GetOrderSummaryHandler(store);                      // => handler initialised
handler.handle("O1")                                                  // => handler.handle() called
    .ifPresent(dto -> System.out.println(dto.status() + " / " + dto.itemCount())); // => output to console
// => PENDING / 3

Kotlin:

// ── Domain type ───────────────────────────────────────────────────────────────
data class OrderId(val value: String)                                 // => class OrderId
data class CustomerId(val value: String)                              // => class CustomerId
 
class Order(val id: OrderId, val customerId: CustomerId, val itemCount: Int) { // => class Order
    var status: String = "PENDING"                                    // => expression
        private set                                                   // => expression
}
 
// ── Query DTO ─────────────────────────────────────────────────────────────────
data class OrderSummaryDto(                                           // => class OrderSummaryDto
    val orderId:    String,                                           // => expression
    val customerId: String,                                           // => expression
    val status:     String,                                           // => expression
    val itemCount:  Int                                               // => expression
)
 
// ── Query handler ─────────────────────────────────────────────────────────────
class GetOrderSummaryHandler(private val store: Map<String, Order>) { // => class GetOrderSummaryHandler
    fun handle(orderId: String): OrderSummaryDto? =                   // => handle method
        store[orderId]?.let { o ->                                    // => expression
            OrderSummaryDto(o.id.value, o.customerId.value, o.status, o.itemCount) // => OrderSummaryDto() called
            // => VO values extracted to primitives; no domain object crosses the boundary
        }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val store   = mapOf("O1" to Order(OrderId("O1"), CustomerId("C1"), 3)) // => store initialised
val handler = GetOrderSummaryHandler(store)                           // => handler initialised
val dto     = handler.handle("O1")                                    // => dto initialised
println("${dto?.status} / ${dto?.itemCount}") // => PENDING / 3

C#:

using System.Collections.Generic;                                     // => namespace/package import
 
public record OrderId(string Value);                                  // => record OrderId
public record CustomerId(string Value);                               // => record CustomerId
 
public class Order(OrderId id, CustomerId customerId, int itemCount)  // => class Order
// => begins block
{
    public OrderId    Id         { get; } = id;                       // => Id field
    public CustomerId CustomerId { get; } = customerId;               // => CustomerId field
    public int        ItemCount  { get; } = itemCount;                // => ItemCount field
    public string     Status     { get; private set; } = "Pending";   // => Status field
}
 
// ── Query DTO: flat, JSON-serialisable ────────────────────────────────────────
public record OrderSummaryDto(string OrderId, string CustomerId, string Status, int ItemCount); // => record OrderSummaryDto
 
public class GetOrderSummaryHandler(Dictionary<string, Order> store)  // => class GetOrderSummaryHandler
{
    public OrderSummaryDto? Handle(string orderId) =>                 // => method declaration
        store.TryGetValue(orderId, out var o)                         // => store.TryGetValue() called
            ? new OrderSummaryDto(o.Id.Value, o.CustomerId.Value, o.Status, o.ItemCount) // => expression
            : null;                                                   // => expression
    // => Domain aggregate mapped to DTO; no aggregate exposed to caller
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var store   = new Dictionary<string, Order> { ["O1"] = new Order(new OrderId("O1"), new CustomerId("C1"), 3) }; // => store initialised
var handler = new GetOrderSummaryHandler(store);                      // => handler initialised
var dto     = handler.Handle("O1");                                   // => dto initialised
System.Console.WriteLine($"{dto?.Status} / {dto?.ItemCount}"); // => Pending / 3

Key Takeaway: Map domain aggregates to Query DTOs at the application layer boundary. Callers receive flat, serialisable data shaped for their needs, not the domain's needs.

Why It Matters: Exposing domain aggregates as API responses ties the API contract to domain internals. When a domain field is renamed for domain clarity, the API breaks. Query DTOs decouple the API shape from the domain shape — each evolves independently. They also prevent callers from accidentally triggering lazy-loaded collections or calling domain methods they should not.


Example 50: Application service idempotency key

An idempotency key ensures that retrying a command (due to network failure, duplicate delivery) produces the same outcome rather than creating duplicate records. The application service stores processed keys and returns the prior result on re-submission.

Java:

import java.util.*;                                                   // => namespace/package import
// => java.util.* provides Map, HashMap, UUID for the idempotency store
 
record OrderId(String value) {                                        // => record OrderId
    static OrderId gen() { return new OrderId(UUID.randomUUID().toString()); } // => gen method
    // => gen() creates a fresh UUID-based OrderId; called on first processing
}
record CustomerId(String value) {}                                    // => record CustomerId
// => Typed customer identity; prevents mixing with other ID types
 
// ── Idempotency store: records processed command keys ─────────────────────────
class IdempotencyStore {                                              // => class IdempotencyStore
    private final Map<String, OrderId> processed = new HashMap<>();   // => Map method
    // => Key: idempotency key supplied by the client for this specific request
    // => Value: OrderId result produced the first time this key was processed
 
    public boolean hasBeenProcessed(String key) {                     // => hasBeenProcessed method
        return processed.containsKey(key);                            // => returns processed.containsKey(key)
        // => O(1) HashMap lookup; true means this key was processed before
    }
 
    public void record(String key, OrderId result) {                  // => record method
        processed.put(key, result);                                   // => processed.put() called
        // => Store result after first successful processing
        // => In production: persisted to a database with TTL (e.g., 24 hours)
    }
 
    public OrderId getResult(String key) {                            // => getResult method
        return processed.get(key);                                    // => returns processed.get(key)
        // => Returns the original result; null if key was never stored
    }
}
 
// ── Application service with idempotency guard ────────────────────────────────
class PlaceOrderService {                                             // => class PlaceOrderService
    private final IdempotencyStore idempotencyStore = new IdempotencyStore(); // => idempotencyStore declared
    // => Shared across calls; persists between requests
    private final Map<String, Object> orderStore = new HashMap<>();   // => Map method
    // => Write store for orders; simplified to Object for brevity
 
    public OrderId placeOrder(String idempotencyKey, CustomerId customerId) { // => placeOrder method
        // ── Check: have we already processed this key? ────────────────────────
        if (idempotencyStore.hasBeenProcessed(idempotencyKey)) {      // => precondition check
            return idempotencyStore.getResult(idempotencyKey);        // => returns idempotencyStore.getResult(ide
            // => Idempotent response: return original result without re-executing
            // => Caller receives same OrderId whether this is attempt #1 or attempt #10
        }
 
        // ── New request: execute business logic, then record result ───────────
        OrderId newId = OrderId.gen();                                // => OrderId.gen() called
        // => New identity generated; this is a genuinely new request
        orderStore.put(newId.value(), customerId);                    // => orderStore.put() called
        // => Persist the order (in production: inside a database transaction)
        idempotencyStore.record(idempotencyKey, newId);               // => idempotencyStore.record() called
        // => Record AFTER persist: if persist fails, we retry the whole operation
        // => If record fails, the order exists but has no idempotency guard — acceptable
        return newId;                                                 // => returns newId
        // => Return new OrderId to the caller for the first time
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var service = new PlaceOrderService();                                // => service initialised
String key  = "req-abc-123";                                          // => expression
// => Client generates this UUID once per business request; retries reuse the same key
 
OrderId first  = service.placeOrder(key, new CustomerId("C1"));       // => service.placeOrder() called
// => First call: new order created; key recorded in idempotency store
OrderId second = service.placeOrder(key, new CustomerId("C1"));       // => service.placeOrder() called
// => Retry: same key detected; original OrderId returned immediately; no new order
 
boolean same = first.value().equals(second.value());                  // => first.value() called
// => true: both variables hold the same OrderId value
System.out.println("Same result: " + same); // => Same result: true

Kotlin:

import java.util.UUID                                                 // => namespace/package import
 
data class OrderId(val value: String) { companion object { fun gen() = OrderId(UUID.randomUUID().toString()) } } // => class OrderId
data class CustomerId(val value: String)                              // => class CustomerId
 
class IdempotencyStore {                                              // => class IdempotencyStore
    private val processed = mutableMapOf<String, OrderId>()           // => processed declared
    fun hasBeenProcessed(key: String) = processed.containsKey(key)    // => hasBeenProcessed method
    fun record(key: String, result: OrderId) { processed[key] = result } // => record method
    fun getResult(key: String) = processed[key]                       // => getResult method
// => ends block
}
 
class PlaceOrderService {                                             // => class PlaceOrderService
    private val idempotency = IdempotencyStore()                      // => idempotency declared
    private val orders      = mutableMapOf<String, CustomerId>()      // => orders declared
 
    fun placeOrder(idempotencyKey: String, customerId: CustomerId): OrderId { // => placeOrder method
        if (idempotency.hasBeenProcessed(idempotencyKey))             // => precondition check
            return idempotency.getResult(idempotencyKey)!!            // => returns idempotency.getResult(idempote
        // => Duplicate request: return original result; no new order
 
        val id = OrderId.gen()                                        // => id initialised
        orders[id.value] = customerId                                 // => expression
        idempotency.record(idempotencyKey, id) // => Store for future retries
        return id                                                     // => returns id
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val service = PlaceOrderService()                                     // => service initialised
val key     = "req-abc-123"                                           // => key initialised
 
val first  = service.placeOrder(key, CustomerId("C1")) // => New order
val second = service.placeOrder(key, CustomerId("C1")) // => Retry; same key
 
println(first == second) // => true — same OrderId returned

C#:

using System; using System.Collections.Generic;                       // => namespace/package import
 
public record OrderId(string Value) { public static OrderId Gen() => new(Guid.NewGuid().ToString()); } // => record OrderId
public record CustomerId(string Value);                               // => record CustomerId
 
public class IdempotencyStore                                         // => class IdempotencyStore
// => begins block
{
    private readonly Dictionary<string, OrderId> _processed = new();  // => Dictionary method
    public bool    HasBeenProcessed(string key) => _processed.ContainsKey(key); // => HasBeenProcessed method
    public void    Record(string key, OrderId r) => _processed[key] = r; // => Record method
    public OrderId GetResult(string key)         => _processed[key];  // => GetResult method
// => ends block
}
 
public class PlaceOrderService                                        // => class PlaceOrderService
// => begins block
{
    private readonly IdempotencyStore              _idempotency = new(); // => idempotency initialised
    private readonly Dictionary<string, CustomerId> _orders     = new(); // => Dictionary method
 
    public OrderId PlaceOrder(string idempotencyKey, CustomerId customerId) // => PlaceOrder method
    // => begins block
    {
        if (_idempotency.HasBeenProcessed(idempotencyKey))            // => precondition check
            return _idempotency.GetResult(idempotencyKey); // => Return cached result
 
        var id = OrderId.Gen();                                       // => id initialised
        _orders[id.Value] = customerId;                               // => expression
        _idempotency.Record(idempotencyKey, id); // => Register result
        return id;                                                    // => returns id
    // => ends block
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var service = new PlaceOrderService();                                // => service initialised
var key     = "req-abc-123";                                          // => key initialised
 
var first  = service.PlaceOrder(key, new CustomerId("C1")); // => New
var second = service.PlaceOrder(key, new CustomerId("C1")); // => Cached
 
Console.WriteLine(first == second); // => True

Key Takeaway: An idempotency key lets clients safely retry commands. The application service returns the original result for repeated keys, preventing duplicate side effects.

Why It Matters: In distributed systems, network failures cause clients to retry requests. Without idempotency, a payment service processes the same charge twice; an order service creates duplicate orders. Idempotency keys, stored after first processing, turn retries into safe re-reads rather than duplicate writes. Every mutation endpoint in a production API should support them.


Concurrency and Errors (Examples 51-54)

Example 51: Optimistic concurrency — version field

Optimistic concurrency prevents lost updates when two transactions read the same aggregate and both try to save. Each aggregate carries a version field; the save operation rejects writes where the version has changed since the read.

Java:

import java.util.*;                                                   // => namespace/package import
 
record OrderId(String value) {}                                       // => record OrderId
 
// ── Aggregate with version field ──────────────────────────────────────────────
class Order {                                                         // => class Order
    private final OrderId id;                                         // => id field
    private String status;                                            // => status field
    private int version; // => Incremented on each save; checked before write
 
    Order(OrderId id, String status, int version) {                   // => Order() called
        this.id = id; this.status = status; this.version = version;   // => this.id assigned
    }
 
    public void confirm() {                                           // => confirm method
        if (!"PENDING".equals(status)) throw new IllegalStateException("Not pending"); // => throws if guard fails
        this.status = "CONFIRMED"; // => State change; version incremented on save
    }
 
    public OrderId getId()    { return id; }                          // => getId method
    public String  getStatus(){ return status; }                      // => getStatus method
    public int     getVersion(){ return version; }                    // => getVersion method
}
 
// ── Repository with optimistic locking ────────────────────────────────────────
class OrderRepository {                                               // => class OrderRepository
    private final Map<String, Order> store = new HashMap<>();         // => Map method
 
    public void save(Order order) { store.put(order.getId().value(), order); } // => save method
 
    public Optional<Order> findById(OrderId id) {                     // => findById method
        return Optional.ofNullable(store.get(id.value()));            // => returns Optional.ofNullable(store.get(
    }
 
    // Optimistic save: compare expected version before writing
    public void saveWithVersionCheck(Order order, int expectedVersion) { // => saveWithVersionCheck method
        Order stored = store.get(order.getId().value());              // => store.get() called
        if (stored != null && stored.getVersion() != expectedVersion) { // => precondition check
            // => Version mismatch: another transaction updated since we read
            throw new ConcurrentModificationException(                // => throws if guard fails
                "Optimistic lock failure: expected version " + expectedVersion + // => expression
                " but found " + stored.getVersion()                   // => stored.getVersion() called
            );                                                        // => expression
        }
        // => Version matches: safe to write; increment version
        var updated = new Order(order.getId(), order.getStatus(), expectedVersion + 1); // => updated initialised
        store.put(updated.getId().value(), updated);                  // => store.put() called
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var repo  = new OrderRepository();                                    // => repo initialised
var order = new Order(new OrderId("O1"), "PENDING", 1);               // => order initialised
repo.save(order); // => version 1 stored
 
// Transaction A reads at version 1
Order txA = repo.findById(new OrderId("O1")).get(); // => version=1
// Transaction B also reads at version 1 and saves first
Order txB = repo.findById(new OrderId("O1")).get(); // => version=1
txB.confirm();                                                        // => txB.confirm() called
repo.saveWithVersionCheck(txB, 1); // => B saves; version becomes 2
 
// Transaction A now tries to save at version 1 — but store is at version 2
txA.confirm();                                                        // => txA.confirm() called
try {                                                                 // => expression
    repo.saveWithVersionCheck(txA, 1); // => throws: expected 1 but found 2
} catch (ConcurrentModificationException e) {                         // => expression
    System.out.println(e.getMessage()); // => Optimistic lock failure: expected 1 but found 2
}

Kotlin:

data class OrderId(val value: String)                                 // => class OrderId
 
data class Order(val id: OrderId, val status: String, val version: Int) { // => class Order
    fun confirm(): Order {                                            // => confirm method
        check(status == "PENDING") { "Not pending" }                  // => precondition check
        return copy(status = "CONFIRMED") // => Returns new Order; version updated on save
    // => ends block
    }
// => ends block
}
 
class OptimisticOrderRepository {                                     // => class OptimisticOrderRepository
    private val store = mutableMapOf<String, Order>()                 // => store declared
 
    fun save(order: Order) { store[order.id.value] = order }          // => save method
    fun findById(id: OrderId) = store[id.value]                       // => findById method
 
    fun saveWithVersionCheck(order: Order, expectedVersion: Int) {    // => saveWithVersionCheck method
        val stored = store[order.id.value]                            // => stored initialised
        if (stored != null && stored.version != expectedVersion)      // => precondition check
            throw ConcurrentModificationException(                    // => throws if guard fails
                "Optimistic lock failure: expected $expectedVersion but found ${stored.version}" // => expression
            )
        store[order.id.value] = order.copy(version = expectedVersion + 1) // => order.copy() called
        // => Version incremented on successful save
    // => ends block
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val repo  = OptimisticOrderRepository()                               // => repo initialised
repo.save(Order(OrderId("O1"), "PENDING", 1))                         // => repo.save() called
 
val txA = repo.findById(OrderId("O1"))!! // => version=1
val txB = repo.findById(OrderId("O1"))!! // => version=1
 
repo.saveWithVersionCheck(txB.confirm(), 1) // => B saves; version becomes 2
 
runCatching { repo.saveWithVersionCheck(txA.confirm(), 1) }           // => repo.saveWithVersionCheck() called
    .onFailure { println(it.message) } // => Optimistic lock failure: expected 1 but found 2

C#:

using System; using System.Collections.Generic;                       // => namespace/package import
 
public record OrderId(string Value);                                  // => record OrderId
 
public class Order(OrderId id, string status, int version)            // => class Order
// => begins block
{
    public OrderId Id      { get; }      = id;                        // => Id field
    public string  Status  { get; private set; } = status;            // => Status field
    public int     Version { get; }      = version;                   // => Version field
 
    public void Confirm()                                             // => Confirm method
    // => begins block
    {
        if (Status != "Pending") throw new InvalidOperationException("Not pending"); // => throws if guard fails
        Status = "Confirmed";                                         // => Status assigned
    // => ends block
    }
// => ends block
}
 
public class OptimisticOrderRepository                                // => class OptimisticOrderRepository
// => begins block
{
    private readonly Dictionary<string, Order> _store = new();        // => Dictionary method
    public void  Save(Order o)          => _store[o.Id.Value] = o;    // => Save method
    public Order? FindById(OrderId id)  => _store.GetValueOrDefault(id.Value); // => method declaration
 
    public void SaveWithVersionCheck(Order order, int expectedVersion) // => SaveWithVersionCheck method
    // => begins block
    {
        if (_store.TryGetValue(order.Id.Value, out var stored) && stored.Version != expectedVersion) // => precondition check
            throw new InvalidOperationException(                      // => throws if guard fails
                $"Optimistic lock failure: expected {expectedVersion} but found {stored.Version}"); // => expression
        var updated = new Order(order.Id, order.Status, expectedVersion + 1); // => updated initialised
        _store[updated.Id.Value] = updated;                           // => expression
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var repo  = new OptimisticOrderRepository();                          // => repo initialised
repo.Save(new Order(new OrderId("O1"), "Pending", 1));                // => repo.Save() called
 
var txA = repo.FindById(new OrderId("O1"))!; // => version=1
var txB = repo.FindById(new OrderId("O1"))!; // => version=1
 
txB.Confirm();                                                        // => txB.Confirm() called
repo.SaveWithVersionCheck(txB, 1); // => B saves; version becomes 2
 
txA.Confirm();                                                        // => txA.Confirm() called
try   { repo.SaveWithVersionCheck(txA, 1); }                          // => repo.SaveWithVersionCheck() called
catch (InvalidOperationException e) { Console.WriteLine(e.Message); } // => output to console
// => Optimistic lock failure: expected 1 but found 2

Key Takeaway: A version field turns concurrent writes into detectable conflicts. The losing transaction receives an explicit exception rather than silently overwriting the winning transaction's changes.

Why It Matters: Without optimistic concurrency, a last-write-wins race condition corrupts aggregate state when two transactions update the same record simultaneously. In e-commerce, this means inventory levels silently become wrong, or payment status is overwritten. The version field makes conflicts visible at the application level, where the domain can decide whether to retry or reject.


Example 52: Domain exception hierarchy

Domain exceptions communicate business rule violations in domain vocabulary. A hierarchy of exception types lets callers handle different categories of domain failure differently.

Java:

// ── Domain exception base ─────────────────────────────────────────────────────
// All domain exceptions extend this; callers catch DomainException for any domain error
public class DomainException extends RuntimeException {               // => RuntimeException field
    public DomainException(String message) { super(message); }        // => DomainException method
    // => Unchecked: callers do not need try-catch unless they want to handle specifically
}
 
// ── Sub-types: more specific domain error categories ──────────────────────────
public class OrderNotFoundException extends DomainException {         // => DomainException field
    private final String orderId;                                     // => orderId field
    public OrderNotFoundException(String orderId) {                   // => OrderNotFoundException method
        super("Order not found: " + orderId);                         // => super() called
        this.orderId = orderId;                                       // => this.orderId assigned
    }
    public String getOrderId() { return orderId; }                    // => getOrderId method
    // => Carries the ID for error response / logging
}
 
public class InvalidOrderTransitionException extends DomainException { // => DomainException field
    public InvalidOrderTransitionException(String from, String to) {  // => InvalidOrderTransitionException method
        super("Invalid order transition: " + from + " → " + to);      // => super() called
    }
}
 
public class InsufficientStockException extends DomainException {     // => DomainException field
    public InsufficientStockException(String productId, int requested, int available) { // => InsufficientStockException method
        super("Insufficient stock for " + productId + ": requested=" + requested + " available=" + available); // => super() called
    }
}
 
// ── Usage in domain methods ────────────────────────────────────────────────────
class Order {                                                         // => class Order
    private String status = "PENDING";                                // => status declared
    private final String id;                                          // => id field
    Order(String id) { this.id = id; }                                // => Order() called
 
    void confirm() {                                                  // => expression
        if (!"PENDING".equals(status))                                // => precondition check
            throw new InvalidOrderTransitionException(status, "CONFIRMED"); // => throws if guard fails
        // => Domain exception: meaningful message in domain vocabulary
        status = "CONFIRMED";                                         // => status assigned
    }
}
 
// ── Usage in application service ──────────────────────────────────────────────
java.util.Map<String, Order> store = new java.util.HashMap<>();       // => expression
store.put("O1", new Order("O1"));                                     // => store.put() called
 
Order order = java.util.Optional.ofNullable(store.get("MISSING"))     // => Optional.ofNullable() called
    .orElseThrow(() -> new OrderNotFoundException("MISSING"));        // => expression
// => throws OrderNotFoundException with message "Order not found: MISSING"
 
// ── Catching at presentation layer ───────────────────────────────────────────
try {                                                                 // => expression
    Order o = store.get("O1");                                        // => store.get() called
    o.confirm();                                                      // => o.confirm() called
    o.confirm(); // => Second confirm: status is now CONFIRMED, not PENDING
} catch (InvalidOrderTransitionException e) {                         // => expression
    System.out.println(e.getMessage()); // => Invalid order transition: CONFIRMED → CONFIRMED
} catch (DomainException e) {                                         // => expression
    System.out.println("Domain error: " + e.getMessage()); // => Any other domain error
}

Kotlin:

// ── Domain exception hierarchy ─────────────────────────────────────────────────
open class DomainException(message: String) : RuntimeException(message) // => class DomainException
 
class OrderNotFoundException(val orderId: String) :                   // => class OrderNotFoundException
    DomainException("Order not found: $orderId")                      // => DomainException() called
 
class InvalidOrderTransitionException(from: String, to: String) :     // => class InvalidOrderTransitionException
    DomainException("Invalid order transition: $from$to")          // => DomainException() called
 
class InsufficientStockException(productId: String, requested: Int, available: Int) : // => class InsufficientStockException
    DomainException("Insufficient stock for $productId: requested=$requested available=$available") // => DomainException() called
 
// ── Usage ─────────────────────────────────────────────────────────────────────
class Order(val id: String) {                                         // => class Order
    var status = "PENDING"                                            // => status initialised
        private set                                                   // => expression
 
    fun confirm() {                                                   // => confirm method
        if (status != "PENDING")                                      // => precondition check
            throw InvalidOrderTransitionException(status, "CONFIRMED") // => throws if guard fails
        status = "CONFIRMED"                                          // => status assigned
    }
}
 
val store = mutableMapOf("O1" to Order("O1"))                         // => store initialised
 
val order = store["MISSING"] ?: throw OrderNotFoundException("MISSING") // => order initialised
// => OrderNotFoundException: Order not found: MISSING
 
try {                                                                 // => expression
    val o = store["O1"]!!                                             // => o initialised
    o.confirm()                                                       // => o.confirm() called
    o.confirm() // => Throws InvalidOrderTransitionException
} catch (e: InvalidOrderTransitionException) {                        // => expression
    println(e.message) // => Invalid order transition: CONFIRMED → CONFIRMED
} catch (e: DomainException) {                                        // => expression
    println("Domain error: ${e.message}")                             // => output to console
}

C#:

using System;                                                         // => namespace/package import
 
// ── Domain exception hierarchy ─────────────────────────────────────────────────
public class DomainException(string message) : Exception(message) {}  // => class DomainException
 
public class OrderNotFoundException(string orderId) :                 // => class OrderNotFoundException
    DomainException($"Order not found: {orderId}")                    // => DomainException() called
// => begins block
{
    public string OrderId { get; } = orderId;                         // => OrderId field
// => ends block
}
 
public class InvalidOrderTransitionException(string from, string to) : // => class InvalidOrderTransitionException
    DomainException($"Invalid order transition: {from} → {to}") {}    // => DomainException() called
 
public class InsufficientStockException(string productId, int requested, int available) : // => class InsufficientStockException
    DomainException($"Insufficient stock for {productId}: requested={requested} available={available}") {} // => DomainException() called
 
// ── Usage ─────────────────────────────────────────────────────────────────────
public class Order(string id)                                         // => class Order
// => begins block
{
    public string Id     { get; } = id;                               // => Id field
    public string Status { get; private set; } = "Pending";           // => Status field
 
    public void Confirm()                                             // => Confirm method
    // => begins block
    {
        if (Status != "Pending")                                      // => precondition check
            throw new InvalidOrderTransitionException(Status, "Confirmed"); // => throws if guard fails
        Status = "Confirmed";                                         // => Status assigned
    // => ends block
    }
// => ends block
}
 
var store = new System.Collections.Generic.Dictionary<string, Order>  // => store initialised
    { ["O1"] = new Order("O1") };                                     // => expression
 
try                                                                   // => expression
{
    var o = store.GetValueOrDefault("MISSING") ?? throw new OrderNotFoundException("MISSING"); // => o initialised
}
catch (OrderNotFoundException e) { Console.WriteLine(e.Message); }    // => output to console
// => Order not found: MISSING
 
try                                                                   // => expression
{
    var o = store["O1"];                                              // => o initialised
    o.Confirm();                                                      // => o.Confirm() called
    o.Confirm(); // => Throws InvalidOrderTransitionException
}
catch (InvalidOrderTransitionException e) { Console.WriteLine(e.Message); } // => output to console
// => Invalid order transition: Confirmed → Confirmed

Key Takeaway: A domain exception hierarchy lets callers handle specific domain failures differently, and gives error messages that domain experts understand.

Why It Matters: Generic exceptions like IllegalArgumentException("bad state") force callers to parse strings to determine the error category — fragile and coupling. Named exception types like OrderNotFoundException are part of the ubiquitous language: controllers map them to HTTP 404; message handlers send them to dead-letter queues. The hierarchy lets a single catch (DomainException) catch all domain errors, or specific catches target specific failures.


Example 53: Result-style return — Either / Try in OOP

A Result type returns success or failure as a first-class value rather than throwing exceptions. Callers must handle both paths, making error handling explicit and preventing unhandled exception leaks.

Java:

import java.util.function.Function;                                   // => namespace/package import
 
// ── Sealed Either type ────────────────────────────────────────────────────────
// Java 17+ sealed interfaces model sum types
sealed interface Either<L, R> permits Either.Left, Either.Right {     // => interface Either
    record Left<L, R>(L value)  implements Either<L, R> {}            // => record Left
    record Right<L, R>(R value) implements Either<L, R> {}            // => record Right
 
    // Factory methods
    static <L, R> Either<L, R> left(L value)  { return new Left<>(value); } // => expression
    static <L, R> Either<L, R> right(R value) { return new Right<>(value); } // => expression
 
    // Map over the right (success) side
    default <T> Either<L, T> map(Function<R, T> f) {                  // => expression
        return switch (this) {                                        // => returns switch (this) {
            case Left<L, R>  l -> Either.left(l.value());   // => Preserve error unchanged
            case Right<L, R> r -> Either.right(f.apply(r.value())); // => Transform success
        };
    }
}
 
// ── Domain operation returning Either ────────────────────────────────────────
record OrderId(String value) { static OrderId gen() { return new OrderId(java.util.UUID.randomUUID().toString()); } } // => record OrderId
record CustomerId(String value) {}                                    // => record CustomerId
 
class PlaceOrderService {                                             // => class PlaceOrderService
    Either<String, OrderId> placeOrder(CustomerId customerId, double total) { // => expression
        if (customerId == null || customerId.value().isBlank())       // => precondition check
            return Either.left("customerId required"); // => Failure path: no exception thrown
        if (total <= 0)                                               // => precondition check
            return Either.left("total must be positive");             // => returns Either.left("total must be pos
        OrderId id = OrderId.gen();                                   // => OrderId.gen() called
        return Either.right(id); // => Success path
    }
}
 
// ── Caller must handle both paths ─────────────────────────────────────────────
var service = new PlaceOrderService();                                // => service initialised
 
Either<String, OrderId> result = service.placeOrder(new CustomerId("C1"), 99.0); // => service.placeOrder() called
switch (result) {                                                     // => switch() called
    case Either.Right<String, OrderId> r -> System.out.println("Created: " + r.value().value()); // => output to console
    case Either.Left<String, OrderId>  l -> System.out.println("Error: " + l.value()); // => output to console
}
// => Created: <uuid>
 
Either<String, OrderId> failed = service.placeOrder(new CustomerId(""), 99.0); // => service.placeOrder() called
switch (failed) {                                                     // => switch() called
    case Either.Right<String, OrderId> r -> System.out.println("Created: " + r.value().value()); // => output to console
    case Either.Left<String, OrderId>  l -> System.out.println("Error: " + l.value()); // => output to console
}
// => Error: customerId required

Kotlin:

import java.util.UUID                                                 // => namespace/package import
 
// Kotlin: sealed class models Either natively
sealed class Either<out L, out R> {                                   // => class Either
    data class Left<L>(val value: L)  : Either<L, Nothing>()          // => class Left
    data class Right<R>(val value: R) : Either<Nothing, R>()          // => class Right
 
    fun <T> map(f: (R) -> T): Either<L, T> = when (this) {            // => expression
        is Left  -> Left(value)   // => Error propagates unchanged
        is Right -> Right(f(value)) // => Success transformed
    // => ends block
    }
// => ends block
}
 
fun <L, R> left(v: L):  Either<L, R> = Either.Left(v)                 // => Either.Left() called
fun <L, R> right(v: R): Either<L, R> = Either.Right(v)                // => Either.Right() called
 
data class OrderId(val value: String) { companion object { fun gen() = OrderId(UUID.randomUUID().toString()) } } // => class OrderId
data class CustomerId(val value: String)                              // => class CustomerId
 
class PlaceOrderService {                                             // => class PlaceOrderService
    fun placeOrder(customerId: CustomerId, total: Double): Either<String, OrderId> { // => placeOrder method
        if (customerId.value.isBlank()) return left("customerId required") // => precondition check
        if (total <= 0)                return left("total must be positive") // => precondition check
        return right(OrderId.gen()) // => Success
    }
}
 
// ── Usage: caller must exhaustively handle both cases ─────────────────────────
val service = PlaceOrderService()                                     // => service initialised
 
when (val result = service.placeOrder(CustomerId("C1"), 99.0)) {      // => service.placeOrder() called
    is Either.Right -> println("Created: ${result.value.value}")      // => output to console
    is Either.Left  -> println("Error: ${result.value}")              // => output to console
} // => Created: <uuid>
 
when (val failed = service.placeOrder(CustomerId(""), 99.0)) {        // => service.placeOrder() called
    is Either.Right -> println("Created: ${failed.value.value}")      // => output to console
    is Either.Left  -> println("Error: ${failed.value}")              // => output to console
} // => Error: customerId required

C#:

using System;   // => Console.WriteLine, Guid
 
// ── Result type ───────────────────────────────────────────────────────────────
// => Abstract record: cannot be instantiated directly; only Ok<T> or Err<T> exist
public abstract record Result<T>  // => generic: T is the success value type
{
    public sealed record Ok<T>(T Value)     : Result<T>;  // => wraps success value
    // => Success case: carries the happy-path value; sealed = not further subclassable
    public sealed record Err<T>(string Msg) : Result<T>; // => wraps error message
    // => Failure case: carries an error message string; sealed = not further subclassable
 
    public static Result<T> Success(T v) => new Ok<T>(v);    // => creates Ok<T> instance
    // => Factory method: creates Ok wrapping v; callers use Success() not new Ok<T>() directly
    public static Result<T> Failure(string m) => new Err<T>(m); // => creates Err<T> instance
    // => Factory method: creates Err wrapping m; symmetric with Success()
}
 
public record OrderId(string Value) { public static OrderId Gen() => new(Guid.NewGuid().ToString()); }
// => Gen() creates a new unique id; record provides structural equality automatically
public record CustomerId(string Value);
// => Typed customer id; Value is the string payload
 
public class PlaceOrderService  // => Application Service: no state; purely functional
// => Application Service: validates input, creates aggregate, returns Result
{
    public Result<OrderId> PlaceOrder(CustomerId customerId, decimal total)
    // => Returns Result<OrderId>: caller MUST handle both Ok and Err branches
    {
        if (string.IsNullOrWhiteSpace(customerId.Value)) return Result<OrderId>.Failure("customerId required");
        // => Validation 1: empty customer id rejected immediately as Err
        if (total <= 0)                                  return Result<OrderId>.Failure("total must be positive");
        // => Validation 2: non-positive total is a domain error, not an exception
        return Result<OrderId>.Success(OrderId.Gen()); // => happy path: new order id returned
        // => Both validations passed: return Ok wrapping a freshly generated order id
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var svc = new PlaceOrderService(); // => fresh service; stateless
// => Fresh service; no state; each call is independent
 
var ok = svc.PlaceOrder(new CustomerId("C1"), 99m); // => valid input
// => Valid input: customerId = "C1", total = 99 (positive)
Console.WriteLine(ok switch {
    Result<OrderId>.Ok<OrderId>  r => $"Created: {r.Value.Value}",  // => success path
    // => Ok case: r.Value is the OrderId; r.Value.Value is the string id
    Result<OrderId>.Err<OrderId> e => $"Error: {e.Msg}",            // => failure path
    // => Err case: e.Msg is the error string
    _ => "unknown" // => exhaustive default for non-sealed abstract record
    // => Exhaustive default; required by compiler for non-sealed abstract records
}); // => Created: <guid>
 
var fail = svc.PlaceOrder(new CustomerId(""), 99m); // => empty customerId
// => Invalid input: empty customerId triggers the first validation
Console.WriteLine(fail switch {
    Result<OrderId>.Ok<OrderId>  r => $"Created: {r.Value.Value}", // => not reached
    Result<OrderId>.Err<OrderId> e => $"Error: {e.Msg}",           // => reached here
    // => Err branch taken: e.Msg = "customerId required"
    _ => "unknown" // => default; not reached in this case
}); // => Error: customerId required

Key Takeaway: A Result type forces callers to handle both success and failure paths explicitly. Errors are values, not exceptional control flow.

Why It Matters: Exceptions used for expected business failures (invalid input, business rule violation) are semantically wrong — exceptions are for unexpected, unrecoverable situations. Result types make the error contract part of the method signature. Callers cannot ignore a Left / Err case without an explicit compiler warning (in exhaustive pattern matching). This eliminates the "swallowed exception" class of bugs where a try-catch with an empty catch block silently discards an error.


Example 54: Validation through Value Object construction (not after)

Validating inside the Value Object constructor means every variable of that type is guaranteed valid. Downstream code never needs to re-validate — the type is the proof of validity.

Java:

import java.util.regex.Pattern;                                       // => namespace/package import
 
// ── Anti-pattern: validation after construction ────────────────────────────────
class AntiPatternEmail {                                              // => class AntiPatternEmail
    public final String value; // => No validation at construction
    public AntiPatternEmail(String value) { this.value = value; }     // => AntiPatternEmail method
// => ends block
}
 
class AntiPatternService {                                            // => class AntiPatternService
    void sendEmail(AntiPatternEmail email) {                          // => expression
        if (email.value == null || !email.value.contains("@")) {      // => precondition check
            throw new IllegalArgumentException("Invalid email: " + email.value); // => throws if guard fails
            // => Validation repeated here and in every other callsite
        }
        System.out.println("Sending to: " + email.value);             // => output to console
    }
}
 
// ── Correct: validation in constructor ────────────────────────────────────────
public final class Email {                                            // => Email field
    private static final Pattern PATTERN = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"); // => PATTERN declared
    private final String value;                                       // => value field
 
    public Email(String value) {                                      // => Email method
        if (value == null || !PATTERN.matcher(value).matches())       // => precondition check
            throw new IllegalArgumentException("Invalid email: " + value); // => throws if guard fails
        // => Validation runs once: at construction; invalid Email cannot exist
        this.value = value.toLowerCase();                             // => this.value assigned
    }
 
    public String getValue() { return value; }                        // => getValue method
    @Override public String toString() { return value; }              // => expression
}
 
// Service can trust that any Email parameter is valid — no re-validation needed
class EmailService {                                                  // => class EmailService
    void send(Email email) {                                          // => expression
        System.out.println("Sending to: " + email); // => No null/format check needed
        // => Type guarantees validity; function focuses on its actual job
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
Email valid = new Email("Alice@Example.com"); // => Normalised to alice@example.com
new EmailService().send(valid); // => Sending to: alice@example.com
 
try {                                                                 // => expression
    new Email("not-an-email"); // => throws immediately
} catch (IllegalArgumentException e) {                                // => expression
    System.out.println(e.getMessage()); // => Invalid email: not-an-email
}
// Downstream: new Email is ALWAYS valid; never need null-check or format-check

Kotlin:

// ── Correct approach: validation in init block ────────────────────────────────
class Email private constructor(val value: String) {                  // => class Email
    init {                                                            // => expression
        require(value.matches(Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"))) { // => precondition check
            "Invalid email: $value"                                   // => expression
        // => ends block
        }
        // => After init passes, value is a valid email; invariant permanent
    }
 
    companion object {                                                // => expression
        fun of(raw: String) = Email(raw.lowercase().trim())           // => of method
        // => Factory: normalises input before validation
    }
 
    override fun toString() = value                                   // => toString method
}
 
fun sendEmail(email: Email) {                                         // => sendEmail method
    println("Sending to: $email") // => Trust the type; no guard needed
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val valid = Email.of("Alice@Example.com") // => email.value = "alice@example.com"
sendEmail(valid) // => Sending to: alice@example.com
 
runCatching { Email.of("bad") }                                       // => Email.of() called
    .onFailure { println(it.message) } // => Invalid email: bad

C#:

using System;                                                         // => namespace/package import
using System.Text.RegularExpressions;                                 // => namespace/package import
 
public sealed record Email                                            // => record Email
// => begins block
{
    private static readonly Regex Pattern = new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled); // => Pattern initialised
    public string Value { get; }                                      // => Value field
 
    public Email(string raw)                                          // => Email method
    // => begins block
    {
        var normalised = raw?.Trim().ToLowerInvariant() ?? "";        // => normalised initialised
        if (!Pattern.IsMatch(normalised))                             // => precondition check
            throw new ArgumentException($"Invalid email: {raw}");     // => throws if guard fails
        Value = normalised; // => Validated and normalised before storing
    // => ends block
    }
 
    public static Email Of(string raw) => new(raw);                   // => Of method
    public override string ToString() => Value;                       // => ToString method
// => ends block
}
 
void SendEmail(Email email)                                           // => expression
// => begins block
{
    Console.WriteLine($"Sending to: {email}"); // => No validation; type is proof
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var valid = Email.Of("Alice@Example.com"); // => "alice@example.com"
SendEmail(valid); // => Sending to: alice@example.com
 
try   { Email.Of("bad"); }                                            // => Email.Of() called
catch (ArgumentException e) { Console.WriteLine(e.Message); } // => Invalid email: bad

Key Takeaway: Validate inside the constructor. Any value of type Email is provably valid — downstream code never re-validates.

Why It Matters: "Parse, don't validate" is the key principle: accept raw input at the boundary, parse it into a typed Value Object, and work only with the typed value inside the system. Scattered validation checks duplicate the rule and risk inconsistency. A constructor-validated type turns the entire domain into a "validated zone": any variable you receive is already valid, shifting focus to domain logic rather than defensive null/format checks.


Strategic Patterns (Example 55)

Example 55: Bounded Context as module / package

A Bounded Context is a named boundary within which a particular domain model is consistent and unambiguous. In code, it maps to a package (Java), module (Kotlin), or namespace (C#) with explicit boundaries. Types from different Bounded Contexts are kept separate and translated at the seam.

graph LR
    subgraph OC["Order Context"]
        OO["Order"]:::blue
        OCust["Customer#40;local#41;"]:::blue
    end
    subgraph CC["Customer Context"]
        CA["CustomerAccount"]:::teal
        CP["CustomerProfile"]:::teal
    end
 
    OC -->|"Anti-Corruption Layer<br/>translate"| CC
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px

Java:

// ── Bounded Context 1: Order Context ──────────────────────────────────────────
// Package: com.example.ordering
// "Customer" in this context means: the party placing an order — minimal view
package com.example.ordering;                                         // => expression
 
record CustomerId(String value) {}   // => ID only; full profile is in Customer Context
record OrderId(String value)    {}                                    // => record OrderId
record Money(java.math.BigDecimal amount, String currency) {}         // => record Money
 
class Order {                                                         // => class Order
    private final OrderId    id;                                      // => id field
    private final CustomerId customerId; // => Only ID: Order Context needs no Customer fields
    private final Money      total;                                   // => total field
 
    Order(OrderId id, CustomerId cid, Money total) {                  // => Order() called
        this.id = id; this.customerId = cid; this.total = total;      // => this.id assigned
    }
    public CustomerId getCustomerId() { return customerId; }          // => getCustomerId method
    public OrderId    getId()         { return id; }                  // => getId method
}
 
// ── Bounded Context 2: Customer Context ───────────────────────────────────────
// Package: com.example.customer
// "Customer" here means: a full account with profile, history, preferences
package com.example.customer;                                         // => expression
 
record CustomerId(String value) {}  // => Same ID concept; separate type in its own namespace
 
record CustomerProfile(                                               // => record CustomerProfile
    com.example.customer.CustomerId id,                               // => expression
    String name,                                                      // => expression
    String email,                                                     // => expression
    String tier    // => Gold, Silver, etc. — concept not visible in Order Context
) {}                                                                  // => expression
 
// ── Anti-Corruption Layer: translates between contexts ─────────────────────────
// Lives at the seam between contexts; prevents model pollution in either direction
class CustomerTranslator {                                            // => class CustomerTranslator
    // Translate Customer Context's CustomerProfile to Order Context's CustomerId
    public com.example.ordering.CustomerId toOrderingCustomerId(CustomerProfile profile) { // => toOrderingCustomerId method
        return new com.example.ordering.CustomerId(profile.id().value()); // => returns new com.example.ordering.Custo
        // => Only the ID crosses the boundary; name, email, tier stay in Customer Context
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var profile    = new CustomerProfile(new com.example.customer.CustomerId("C1"), // => profile initialised
                                     "Alice", "alice@example.com", "Gold"); // => expression
var translator = new CustomerTranslator();                            // => translator initialised
var orderingId = translator.toOrderingCustomerId(profile);            // => orderingId initialised
// => com.example.ordering.CustomerId("C1") — only the ID; no Gold tier leak

Kotlin:

// ── Bounded Context separation via package objects ─────────────────────────────
 
// Order Context: com.example.ordering
object OrderingContext {                                              // => object OrderingContext
    data class CustomerId(val value: String) // => Local definition; different from CustomerContext.CustomerId
    data class OrderId(val value: String)                             // => class OrderId
    data class Order(val id: OrderId, val customerId: CustomerId)     // => class Order
}
 
// Customer Context: com.example.customer
object CustomerContext {                                              // => object CustomerContext
    data class CustomerId(val value: String)                          // => class CustomerId
    data class CustomerProfile(val id: CustomerId, val name: String, val tier: String) // => class CustomerProfile
}
 
// ── Anti-Corruption Layer ─────────────────────────────────────────────────────
object CustomerTranslator {                                           // => object CustomerTranslator
    fun toOrderingCustomerId(profile: CustomerContext.CustomerProfile): OrderingContext.CustomerId = // => toOrderingCustomerId method
        OrderingContext.CustomerId(profile.id.value)                  // => OrderingContext.CustomerId() called
    // => Only the ID crosses; tier and name stay in Customer Context
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
val profile    = CustomerContext.CustomerProfile(CustomerContext.CustomerId("C1"), "Alice", "Gold") // => profile initialised
val orderingId = CustomerTranslator.toOrderingCustomerId(profile)     // => orderingId initialised
// => OrderingContext.CustomerId(value=C1) — tier invisible in Order Context

C#:

// ── Bounded Context separation via namespaces ─────────────────────────────────
// Each namespace is a Bounded Context boundary in the code
// => Types with the same name (e.g., CustomerId) exist in BOTH namespaces independently
 
namespace Example.Ordering                                            // => expression
{
    public record CustomerId(string Value);                           // => record CustomerId
    // => Ordering Context's CustomerId: contains only the ID needed to reference a customer
    public record OrderId(string Value);                              // => record OrderId
    // => Ordering Context's OrderId: typed identity for orders
    public record Order(OrderId Id, CustomerId CustomerId);           // => record Order
    // => Order stores CustomerId by reference only; no CustomerProfile fields
    // => Ordering Context has no knowledge of customer tier, email, or name
}
 
namespace Example.Customer                                            // => expression
{
    public record CustomerId(string Value);                           // => record CustomerId
    // => Customer Context's CustomerId: same concept, independently defined
    // => Both namespaces defining their own CustomerId prevents cross-context type leakage
    public record CustomerProfile(CustomerId Id, string Name, string Email, string Tier); // => record CustomerProfile
    // => CustomerProfile: rich view of the customer as Customer Context sees them
    // => "Tier" (Gold/Silver/Bronze) is a concept meaningful ONLY within Customer Context
    // => Ordering Context must not depend on this concept to avoid tight coupling
}
 
namespace Example.Ordering.Acl  // ACL = Anti-Corruption Layer        // => expression
// => ACL namespace lives inside Ordering but explicitly depends on Customer Context
{
    using Example.Customer;                                           // => namespace/package import
    // => Import Customer Context types here ONLY; never in the core Ordering namespace
 
    public class CustomerTranslator                                   // => class CustomerTranslator
    {
        public Example.Ordering.CustomerId Translate(CustomerProfile profile) => // => Translate method
            new(profile.Id.Value);                                    // => new() called
            // => Extract only the ID value from CustomerProfile
            // => Name, Tier, Email are deliberately discarded — Ordering doesn't need them
            // => If Customer Context renames Tier → MembershipLevel, only this file changes
    }
}
 
// ── Usage ─────────────────────────────────────────────────────────────────────
var profile = new Example.Customer.CustomerProfile(                   // => profile initialised
    new Example.Customer.CustomerId("C1"),                            // => Customer.CustomerId() called
    // => Customer Context's CustomerId wraps "C1"
    "Alice", "alice@example.com", "Gold"                              // => expression
    // => Name, email, and tier are Customer Context concerns; Ordering ignores them
);                                                                    // => expression
var translator = new Example.Ordering.Acl.CustomerTranslator();       // => translator initialised
// => ACL translator lives at the seam; neither pure Ordering nor pure Customer
var orderingId = translator.Translate(profile);                       // => orderingId initialised
// => Example.Ordering.CustomerId { Value = C1 }
// => tier "Gold" is invisible here — successfully blocked at the ACL boundary

Key Takeaway: Each Bounded Context owns its model independently. An Anti-Corruption Layer translates between contexts at their seam, preventing model concepts from bleeding across boundaries.

Why It Matters: When two contexts share types directly, a change in the Customer Context's CustomerProfile breaks the Order Context's code. Bounded Context separation means each team owns their model and can evolve it without coordinating every change with other teams. The Anti-Corruption Layer is the explicit translation contract — when the Customer Context renames tier to membershipLevel, only the translator changes, not the entire Order Context.

Last updated May 8, 2026

Command Palette

Search for a command to run...