Beginner
Examples 1–25 walk through DDD tactical patterns using an e-commerce order domain. Every code block is self-contained and runnable. Annotation density targets 1.0–2.25 comment lines per code line.
Ubiquitous Language and Value Objects (Examples 1–5)
Example 1: Ubiquitous Language — naming domain types
Every class name should come directly from the domain glossary. When the code says Order, Customer, and Money, developers and domain experts share a single vocabulary — no translation layer, no OrderDTO or OrderData.
Java:
// Ubiquitous Language: names come from the domain glossary, not technical layers
// => "Order" not "OrderData"; "Money" not "BigDecimal"; "Quantity" not "int"
public record Order(
OrderId id, // => Strongly-typed id, not raw String or long
CustomerId customerId, // => Makes relationship explicit at the type level
Money total, // => Money carries currency — BigDecimal does not
Quantity quantity // => Business concept, not primitive int
) {}
// Anti-pattern: primitive names lose all domain meaning
// => String id1, String id2 — which is order id? which is customer id?
// => double amount — what currency? what unit?
public record AntiPattern(String id1, String id2, double amount, int count) {}
// Usage: UL version reads like a business requirement
Order o = new Order(
new OrderId("ord-1"), // => Typed; wrong id kind caught at compile time
new CustomerId("cust-7"), // => Cannot accidentally pass OrderId here
new Money("50.00", "USD"), // => Currency embedded in the value
new Quantity(3) // => Quantity, not "3 of what?"
);Kotlin:
// Kotlin: same UL principle; data class adds structural equality automatically
// => Names are identical to Java version — UL is language-agnostic
data class Order( // => class Order
val id: OrderId, // => val = immutable after construction
val customerId: CustomerId, // => Separate type prevents id mix-up
val total: Money, // => Money, not Double
val quantity: Quantity // => Domain concept elevated to type
)
// Factory usage mirrors domain expert speech: "place an order for 3 items"
val order = Order( // => order initialised
id = OrderId("ord-1"), // => Named params improve call-site clarity
customerId = CustomerId("cust-7"), // => Explicit; no positional confusion
total = Money("50.00", "USD"), // => Currency always present
quantity = Quantity(3) // => Type enforces positive int constraint
)C#:
// C# record: init-only properties enforce immutability alongside UL naming
// => Primary constructor syntax keeps declaration concise
public record Order( // => record Order
OrderId Id, // => Typed identity; init-only after construction
CustomerId CustomerId, // => Pascal case follows C# convention; still UL
Money Total, // => Money not decimal; carries currency semantics
Quantity Quantity // => Business concept, not int
); // => expression
// Instantiation reads like business language
var order = new Order( // => order initialised
Id: new OrderId("ord-1"), // => Named args clarify intent
CustomerId: new CustomerId("cust-7"), // => Compile-time id safety
Total: new Money(50.00m, "USD"), // => Currency locked at creation
Quantity: new Quantity(3) // => Quantity validates positive value
); // => expressionKey Takeaway: Name every domain type using exact vocabulary from the domain glossary. When code reads like business requirements, the translation gap that causes most logic bugs disappears.
Why It Matters: Teams using Ubiquitous Language spend less time in requirements-clarification meetings because there is no silent translation between "order" and OrderData. When a developer says "Order" and the domain expert hears "Order" and the code shows Order, misunderstandings surface during code review rather than in production. This single practice eliminates an entire category of specification drift in long-running enterprise systems.
Example 2: Value Object — immutable Money
A Value Object has no identity — two Money instances with the same amount and currency are identical. Immutability guarantees no side-effects: you cannot accidentally mutate a shared Money reference.
%% Palette: Blue #0173B2, Teal #029E73, Orange #DE8F05
graph LR
A["Money #40;Value Object#41;"]:::blue
B["amount: BigDecimal"]:::teal
C["currency: String"]:::teal
D["add#40;Money#41; → Money"]:::orange
A --> B
A --> C
A --> D
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
Java:
import java.math.BigDecimal; // => BigDecimal: exact decimal arithmetic; never use double for money
import java.util.Objects; // => Objects.hash: null-safe hash helper
// Value Object: identity-free; two instances with equal fields ARE equal
// => No id field anywhere — Money IS defined by amount + currency
public final class Money { // => final: cannot be subclassed; value semantics preserved
private final BigDecimal amount; // => final: immutable after construction
private final String currency; // => final: currency locked at creation
public Money(BigDecimal amount, String currency) { // => Money method
// => Validate before storing; invalid Money cannot exist as an object
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) // => precondition check
throw new IllegalArgumentException("Amount must be non-negative"); // => fail fast
// => Currency must be non-blank; "USD", "EUR" etc.
if (currency == null || currency.isBlank()) // => precondition check
throw new IllegalArgumentException("Currency required"); // => empty string rejected
this.amount = amount; // => Stored; no setter exists on this class
this.currency = currency; // => Stored; cannot change after this line
}
// Operations produce NEW instances — original is unchanged
public Money add(Money other) { // => returns Money; never void; immutable design
// => Mismatched currency is a domain error, not a rounding issue
if (!this.currency.equals(other.currency)) // => precondition check
throw new IllegalArgumentException("Currency mismatch: " + currency + " vs " + other.currency); // => throws if guard fails
// => detect mix-up at the domain boundary; fail before arithmetic proceeds
return new Money(this.amount.add(other.amount), this.currency); // => returns new Money(this.amount.add(othe
// => new Money returned; neither this nor other is mutated
}
public BigDecimal getAmount() { return amount; } // => read-only accessor
public String getCurrency() { return currency; } // => read-only accessor
@Override public boolean equals(Object o) { // => override required for structural equality
// => Structural equality: same amount+currency = interchangeable
if (!(o instanceof Money m)) return false; // => pattern variable m; type-safe cast
return amount.compareTo(m.amount) == 0 && currency.equals(m.currency); // => returns amount.compareTo(m.amount) ==
// => compareTo not equals for BigDecimal: "10.00".equals("10") is false; compareTo == 0 is true
}
@Override public int hashCode() { // => must match equals; same fields, same hash
return Objects.hash(amount.stripTrailingZeros(), currency); // => returns Objects.hash(amount.stripTrail
// => stripTrailingZeros: "10.00" and "10" hash equally — consistent with compareTo
}
@Override public String toString() { return amount + " " + currency; } // => "10.00 USD"
}
Money price = new Money(new BigDecimal("10.00"), "USD"); // => price = 10.00 USD
Money tax = new Money(new BigDecimal("1.00"), "USD"); // => tax = 1.00 USD
Money total = price.add(tax); // => total = 11.00 USD (brand-new object)
// price is still 10.00 USD — immutability guaranteed; no shared-state bug possibleKotlin — data class generates equals/hashCode/toString automatically:
import java.math.BigDecimal // => namespace/package import
// data class: structural equality and copy() generated; val fields = immutable
// => No manual equals/hashCode; Kotlin compiler produces correct implementations
data class Money(val amount: BigDecimal, val currency: String) { // => class Money
init { // => expression
// => init block runs after primary constructor; enforces invariants
require(amount >= BigDecimal.ZERO) { "Amount must be non-negative: $amount" } // => precondition check
require(currency.isNotBlank()) { "Currency must not be blank" } // => precondition check
// => If require fails, IllegalArgumentException thrown automatically
}
fun add(other: Money): Money { // => add method
// => Reject mixed-currency; domain rule enforced here, not in callers
require(currency == other.currency) { "Currency mismatch: $currency vs ${other.currency}" } // => precondition check
return Money(amount + other.amount, currency) // => new instance; inputs unchanged
}
}
val price = Money(BigDecimal("10.00"), "USD") // => Money(amount=10.00, currency=USD)
val tax = Money(BigDecimal("1.00"), "USD") // => Money(amount=1.00, currency=USD)
val total = price.add(tax) // => Money(amount=11.00, currency=USD)
// price is still Money(amount=10.00) — val fields cannot be reassignedC# — record provides with-expressions and built-in structural equality:
// sealed record: init-only properties, structural ==, with-expressions built in
// => No manual Equals() or GetHashCode(); compiler generates from all properties
public sealed record Money // => record Money
{
public decimal Amount { get; init; } // => init-only: set once, then immutable
public string Currency { get; init; } // => init-only: currency locked after ctor
public Money(decimal amount, string currency) // => Money method
{
// => Validate before assigning; invalid Money cannot be constructed
if (amount < 0) throw new ArgumentException("Negative amount"); // => throws if guard fails
if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException("Currency required"); // => throws if guard fails
Amount = amount; // => Assigned once; init property blocks future mutation
Currency = currency; // => Assigned once
}
public Money Add(Money other) // => Add method
{
// => Currency must match; no silent conversion allowed
if (Currency != other.Currency) throw new InvalidOperationException("Currency mismatch"); // => throws if guard fails
return this with { Amount = Amount + other.Amount }; // => returns this with { Amount = Amount +
// => with-expression produces a new record; this is unchanged
}
}
var price = new Money(10.00m, "USD"); // => { Amount=10.00, Currency="USD" }
var tax = new Money(1.00m, "USD"); // => { Amount=1.00, Currency="USD" }
var total = price.Add(tax); // => { Amount=11.00, Currency="USD" }
// price still { Amount=10.00 } — init-only; with-expression created a new recordKey Takeaway: A Value Object is defined by its attributes alone. Immutability means shared references are safe — no caller can corrupt a Money value you handed out.
Why It Matters: Financial systems that use raw double or BigDecimal fields suffer from silent currency-mixing bugs and mutation surprises. Elevating money to a first-class Value Object moves currency-mismatch detection to construction time and type-checking, not to test or production. Every financial domain — banking, e-commerce, payroll — gains immediately from this pattern.
Example 3: Value Object equality — structural, not reference
Two distinct Value Object instances with identical attributes must compare as equal. This structural equality distinguishes a Value Object from an Entity, and most OOP languages require deliberate implementation.
Java:
import java.util.Objects; // => namespace/package import
// EmailAddress: single-field Value Object; equality must be structural
// => Two EmailAddress("a@b.com") instances must be equal even as different objects
public final class EmailAddress { // => EmailAddress field
private final String value; // => normalised lowercase; single source of truth
public EmailAddress(String value) { // => EmailAddress method
// => Validate format; reject null and missing '@'
if (value == null || !value.contains("@")) // => precondition check
throw new IllegalArgumentException("Invalid email: " + value); // => throws if guard fails
this.value = value.toLowerCase(); // => normalise; equality is case-insensitive
}
// Structural equality: compare field, not memory address
@Override public boolean equals(Object o) { // => expression
if (!(o instanceof EmailAddress e)) return false; // => type-safe pattern variable
return value.equals(e.value); // => attribute comparison, not reference comparison
}
@Override public int hashCode() { return Objects.hash(value); } // => hash must match equals
@Override public String toString() { return value; } // => "alice@example.com"
}
EmailAddress a = new EmailAddress("Alice@Example.com"); // => normalised: alice@example.com
EmailAddress b = new EmailAddress("alice@example.com"); // => normalised: alice@example.com
boolean structural = a.equals(b); // => true — same attribute values
boolean reference = (a == b); // => false — different heap objects
// Without overriding equals(), structural would be false — a common VO bugKotlin:
// Kotlin data class: equals() compares all val fields automatically
// => No manual override needed; data class IS the correct choice for Value Objects
data class EmailAddress(val value: String) { // => class EmailAddress
init { // => expression
// => Validate in init; invalid email cannot be constructed
require(value.isNotBlank() && value.contains("@")) { "Invalid email: $value" } // => precondition check
}
}
val a = EmailAddress("alice@example.com") // => EmailAddress(value=alice@example.com)
val b = EmailAddress("alice@example.com") // => EmailAddress(value=alice@example.com)
val structural = a == b // => true — data class == delegates to field comparison
val reference = a === b // => false — different object references in memory
// == is structural; === is referential — data class makes == do the right thingC#:
// C# record: == operator uses structural equality automatically
// => Compiler generates Equals() comparing all init-only properties
public record EmailAddress(string Value) // => record EmailAddress
{
// Primary constructor syntax; Value is init-only
public EmailAddress(string value) : this(value.ToLowerInvariant()) // => EmailAddress method
{
// => Normalise to lowercase before calling the generated init
if (!value.Contains('@')) throw new ArgumentException("Invalid email"); // => throws if guard fails
// => Validation after normalisation; Value property now holds lowercase
}
}
var a = new EmailAddress("Alice@Example.com"); // => Value = "alice@example.com"
var b = new EmailAddress("alice@example.com"); // => Value = "alice@example.com"
bool structural = a == b; // => true — record == is structural
bool reference = ReferenceEquals(a, b); // => false — different objects
// C# record provides correct VO equality without a single line of boilerplateKey Takeaway: Value Object equality must compare attributes, not memory addresses. Without overriding equals/hashCode in Java, two logically identical values behave as unequal — a common source of bugs in collections and caches.
Why It Matters: Dictionary lookups, Set membership, and unit-test assertions all depend on correct equality semantics. When EmailAddress("a@b.com").equals(EmailAddress("a@b.com")) returns false, duplicates accumulate in sets and caches return stale values silently. These bugs are notoriously hard to trace because the values look identical when logged. Structural equality in Value Objects eliminates this entire class of problem by making equality a property of value, not object identity.
Example 4: Value Object validation in constructor (smart constructor)
Moving validation into the constructor ensures that an invalid Value Object can never be constructed. The type itself becomes proof of validity — any variable of type PostalCode is a guaranteed-valid postal code.
Java:
import java.util.regex.Pattern; // => namespace/package import
// Smart constructor: validation is mandatory, not optional
// => Caller cannot construct a PostalCode and then forget to validate
public final class PostalCode { // => PostalCode field
private static final Pattern US = Pattern.compile("^\\d{5}(-\\d{4})?$"); // => US declared
// => Compiled once at class load; cheap to reuse on every validation
private final String value; // => final: immutable after validation passes
public PostalCode(String value) { // => PostalCode method
// => Validate before storing; invalid cannot escape this constructor
if (value == null || !US.matcher(value).matches()) // => precondition check
throw new IllegalArgumentException("Invalid US postal code: " + value); // => throws if guard fails
this.value = value; // => Only reachable if pattern matched
}
// Static factory: same validation, reads more naturally at call sites
public static PostalCode of(String v) { return new PostalCode(v); } // => of method
// => PostalCode.of("10001") vs new PostalCode("10001") — both identical behaviour
public String getValue() { return value; } // => read-only
@Override public String toString() { return value; } // => "10001"
}
PostalCode valid = PostalCode.of("10001"); // => Succeeds; value = "10001"
// PostalCode bad = PostalCode.of("abc"); // => throws IllegalArgumentException
// After construction, every PostalCode variable is guaranteed valid — no null-checks downstreamKotlin:
// private constructor + companion factory: callers must use of(); cannot bypass validation
class PostalCode private constructor(val value: String) { // => class PostalCode
// => private constructor: prevents direct new PostalCode("abc")
init { // => expression
// => init runs after constructor; validates invariant
require(value.matches(Regex("^\\d{5}(-\\d{4})?\$"))) { // => precondition check
"Invalid US postal code: $value" // => message embedded in require
}
}
companion object { // => expression
fun of(value: String): PostalCode = PostalCode(value) // => of method
// => Factory delegates to private constructor; same validation path
}
override fun toString() = value // => prints as raw string, not PostalCode(value=10001)
}
val valid = PostalCode.of("10001") // => Succeeds; valid.value = "10001"
// val bad = PostalCode.of("abc") // => throws IllegalArgumentException
// Every PostalCode reference is structurally guaranteed valid — invariant held by typeC#:
using System.Text.RegularExpressions; // => namespace/package import
// sealed record: immutable after construction; static factory for fluent API
public sealed record PostalCode // => record PostalCode
{
private static readonly Regex Us = new(@"^\d{5}(-\d{4})?$", RegexOptions.Compiled); // => Us initialised
// => static readonly: compiled once; RegexOptions.Compiled = faster repeat usage
public string Value { get; } // => get-only: no setter; immutable after ctor
public PostalCode(string value) // => PostalCode method
{
// => Validate before assigning; invalid cannot escape constructor
if (!Us.IsMatch(value ?? string.Empty)) // => precondition check
throw new ArgumentException($"Invalid US postal code: {value}"); // => throws if guard fails
Value = value; // => Only assigned when pattern matched
}
public static PostalCode Of(string v) => new(v); // => Of method
// => PostalCode.Of("10001") — static factory; identical validation path
public override string ToString() => Value; // => "10001"
}
var valid = PostalCode.Of("10001"); // => Value = "10001"
// var bad = PostalCode.Of("abc"); // => throws ArgumentException
// Downstream code needs no format-check — the type proves validityKey Takeaway: Validate in the constructor so that constructing the object IS the validation. A successfully constructed PostalCode is a proof — no further null-checks or format-checks are needed.
Why It Matters: When validation is optional and scattered across callers, invalid values reach deeper layers where their origin is hard to trace. Centralising validation in the constructor creates a single failure point: if construction succeeds, the value is valid everywhere. This principle — "make illegal states unrepresentable" — is one of the highest-leverage design moves in domain modelling.
Example 5: Value Object composition — Address from primitives
Complex Value Objects compose simpler validated ones. An Address that composes Street, City, and PostalCode objects inherits their validations — a valid Address is proof that each constituent is valid.
%% Palette: Blue #0173B2, Teal #029E73, Orange #DE8F05, Brown #CA9161
graph TD
A["Address #40;Composed VO#41;"]:::blue
B["Street #40;VO#41;"]:::teal
C["City #40;VO#41;"]:::teal
D["PostalCode #40;VO#41;"]:::teal
E["non-blank string"]:::orange
F["non-blank string"]:::orange
G["regex-validated string"]:::brown
A --> B
A --> C
A --> D
B --> E
C --> F
D --> G
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef brown fill:#CA9161,stroke:#000,color:#fff,stroke-width:2px
Java:
// Leaf Value Objects: each wraps a validated primitive
public record Street(String value) { // => record Street
public Street { // => compact constructor validates in-place
if (value == null || value.isBlank()) throw new IllegalArgumentException("Street required"); // => throws if guard fails
// => blank street is a domain error; rejected at the Street boundary
}
}
public record City(String value) { // => record City
public City { // => compact constructor
if (value == null || value.isBlank()) throw new IllegalArgumentException("City required"); // => throws if guard fails
// => same pattern: invalid city cannot exist as a City object
}
}
// PostalCode from Example 4 — already validates regex
// Composed VO: valid Address proves valid Street, City, and PostalCode
public record Address(Street street, City city, PostalCode postalCode) { // => record Address
public Address { // => expression
// => Null check at composition level; each sub-VO validated itself
if (street == null || city == null || postalCode == null) // => precondition check
throw new IllegalArgumentException("All address fields required"); // => throws if guard fails
// => If street/city/postalCode were constructed, they are already valid
}
}
Address addr = new Address( // => expression
new Street("123 Main St"), // => Street validated: non-blank
new City("Springfield"), // => City validated: non-blank
PostalCode.of("10001") // => PostalCode validated: regex
); // => addr is fully valid; no further checks needed anywhereKotlin:
// Leaf VOs: data classes with init validation
data class Street(val value: String) { // => class Street
init { require(value.isNotBlank()) { "Street required" } } // => value.isNotBlank() called
// => require throws IllegalArgumentException on blank
}
data class City(val value: String) { // => class City
init { require(value.isNotBlank()) { "City required" } } // => value.isNotBlank() called
// => each VO guards its own invariant
}
// Composed VO: structural equality covers all nested fields via data class
data class Address(val street: Street, val city: City, val postalCode: PostalCode) { // => class Address
init { // => expression
// => Kotlin's require checks null via non-null types; this guards logical completeness
require(street.value.isNotBlank() && city.value.isNotBlank()) { "All fields required" } // => precondition check
// => postalCode already validated; composition cascades correctness
}
}
val address = Address( // => address initialised
Street("123 Main St"), // => validated non-blank
City("Springfield"), // => validated non-blank
PostalCode.of("10001") // => validated regex
) // => address is sound; every field proven valid by its own constructorC#:
// Leaf VOs: records with validation in constructor
public record Street(string Value) // => record Street
// => begins block
{
public Street(string value) : this(value) // => Street method
// => begins block
{
if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Street required"); // => throws if guard fails
// => rejects blank; primary ctor sets Value
// => ends block
}
// => ends block
}
public record City(string Value) // => record City
// => begins block
{
public City(string value) : this(value) // => City method
// => begins block
{
if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("City required"); // => throws if guard fails
// => same guard pattern for consistency
// => ends block
}
}
// Composed VO: == operator compares all three nested records structurally
public record Address(Street Street, City City, PostalCode PostalCode) // => record Address
{
public Address(Street street, City city, PostalCode postalCode) : this(street, city, postalCode) // => Address method
{
// => null-guard; sub-VOs already validated their own values
ArgumentNullException.ThrowIfNull(street); // => ArgumentNullException.ThrowIfNull() called
ArgumentNullException.ThrowIfNull(city); // => ArgumentNullException.ThrowIfNull() called
ArgumentNullException.ThrowIfNull(postalCode); // => ArgumentNullException.ThrowIfNull() called
}
}
var address = new Address( // => address initialised
new Street("123 Main St"), // => validated: non-blank
new City("Springfield"), // => validated: non-blank
PostalCode.Of("10001") // => validated: regex
); // => address fully valid; downstream code reads fields without defensive checksKey Takeaway: Compose validated Value Objects rather than raw strings. If construction succeeds, validity is guaranteed end-to-end — no defensive checks scattered across callers.
Why It Matters: Passing raw strings through an address object means every caller must re-validate. Composing validated leaf VOs pushes that cost to one moment — construction — and makes "invalid address" a compile-time impossibility rather than a runtime concern. This is the difference between defensive programming (check everywhere) and design-by-contract (prove it once).
Entities (Examples 6–12)
Example 6: Entity — identity field anchors lifetime
An Entity has a unique identity that persists across its entire lifetime. Two Order objects with different IDs are different orders even if every other field is identical. The ID, not the data, defines the Entity.
%% Palette: Blue #0173B2, Teal #029E73, Orange #DE8F05
graph LR
A["Order #40;Entity#41;"]:::blue
B["OrderId — FIXED"]:::teal
C["total: Money — mutable"]:::orange
D["status: OrderStatus — mutable"]:::orange
A -->|"identity"| B
A -->|"state"| C
A -->|"state"| D
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
Java:
import java.util.UUID; // => namespace/package import
// Entity: identity is the anchor; state changes do not alter identity
// => id is final; total and status are mutable — that is the correct split
public class Order { // => Order field
private final OrderId id; // => final: identity never changes after construction
private Money total; // => mutable: total grows as lines are added
private OrderStatus status; // => mutable: moves through lifecycle states
public Order(OrderId id, Money initialTotal) { // => Order method
this.id = id; // => identity locked here, forever
this.total = initialTotal; // => initial state; mutable via behaviour methods
this.status = OrderStatus.PENDING; // => all orders begin in PENDING state
}
public OrderId getId() { return id; } // => expose identity for reference
public Money getTotal() { return total; } // => read current state
public OrderStatus getStatus() { return status; } // => read current lifecycle position
}
// OrderId is itself a Value Object — typed identity
public record OrderId(UUID value) { // => record OrderId
// => record provides structural equality; two OrderId with same UUID are equal
public static OrderId generate() { return new OrderId(UUID.randomUUID()); } // => generate method
// => static factory: creates a globally unique identity
}
OrderId id = OrderId.generate(); // => e.g. OrderId[value=3fa8...]
Order order = new Order(id, new Money(new java.math.BigDecimal("0"), "USD")); // => math.BigDecimal() called
// => order.id is permanent; order.total and order.status will change over its lifetimeKotlin:
import java.util.UUID // => namespace/package import
// Entity: use class not data class — data class would compare all fields, breaking entity semantics
// => equals() by id only (overridden below); data class equals() by all fields is WRONG for entities
class Order( // => class Order
val id: OrderId, // => val: identity immutable; this is what "same order" means
initialTotal: Money // => constructor param; stored as var property below
) { // => expression
var total: Money = initialTotal // => var: mutable state
private set // => only mutated through behaviour methods in this class
var status: OrderStatus = OrderStatus.PENDING // => starts PENDING
private set // => expression
// Identity-based equality: two Order references with same id are equal
override fun equals(other: Any?) = other is Order && id == other.id // => equals method
override fun hashCode() = id.hashCode() // => hash matches equals; id-only
}
@JvmInline value class OrderId(val value: UUID) { // => class OrderId
// => value class: zero-overhead wrapper; erased to UUID at runtime
companion object { fun generate() = OrderId(UUID.randomUUID()) } // => UUID.randomUUID() called
// => generate() is the one creation path; callers never see raw UUIDs
}
val order = Order(OrderId.generate(), Money(java.math.BigDecimal.ZERO, "USD")) // => order initialised
// => order.id is fixed; order.total and order.status change through lifecycleC#:
using System; // => namespace/package import
// Entity: class not record — record would use structural equality, wrong for entities
// => override Equals to compare by id only; class default is reference equality
public class Order // => class Order
{
public OrderId Id { get; } // => init-only: identity locked in ctor
public Money Total { get; private set; } // => private set: mutable via methods only
public OrderStatus Status { get; private set; } = OrderStatus.Pending; // => Status field
// => Defaults to Pending; private set enforces encapsulation
public Order(OrderId id, Money initialTotal) // => Order method
{
Id = id; // => identity locked; no setter to reassign it
Total = initialTotal; // => initial state; changes via behaviour methods
}
// Identity-based equality: same id = same order, regardless of current state
public override bool Equals(object? obj) => obj is Order o && Id == o.Id; // => Equals method
public override int GetHashCode() => Id.GetHashCode(); // => id-based hash
}
public record OrderId(Guid Value) // => record OrderId
{
// => record gives structural ==; two OrderId with same Guid are equal
public static OrderId Generate() => new(Guid.NewGuid()); // => new unique id
}
var order = new Order(OrderId.Generate(), new Money(0m, "USD")); // => order initialised
// => order.Id is fixed; order.Total and order.Status change through lifecycleKey Takeaway: An Entity's identity is fixed at birth and never changes. State changes do not alter identity — the same Order is the same order from creation to fulfillment.
Why It Matters: In business systems, things like orders, customers, and accounts persist through state changes. Without a stable identity anchor, the system cannot track "this specific order" across database updates, distributed messages, and state transitions. A customer who changes their email address must still be the same customer — their entire order history must remain associated with them. Identity is the bedrock of business continuity in enterprise software.
Example 7: Entity equality — by ID only, ignores other fields
Entity equality compares only identity fields. Two Customer references with different in-memory states are equal if they share the same CustomerId. Field values play no role.
Java:
import java.util.Objects; // => namespace/package import
public class Customer { // => Customer field
private final CustomerId id; // => identity; used exclusively in equals/hashCode
private String name; // => mutable state — NOT part of equality
private EmailAddress email; // => mutable state — NOT part of equality
public Customer(CustomerId id, String name, EmailAddress email) { // => Customer method
this.id = id; // => identity locked
this.name = name; // => initial state; can change without affecting equality
this.email = email; // => initial state; can change without affecting equality
}
// Equality by id ONLY — not name, not email
@Override public boolean equals(Object o) { // => expression
if (!(o instanceof Customer c)) return false; // => precondition check
return id.equals(c.id); // => only id compared; name/email irrelevant to identity
}
@Override public int hashCode() { return Objects.hash(id); } // => hash from id only
public void rename(String newName) { this.name = newName; } // => state change; equality unchanged
public CustomerId getId() { return id; } // => getId method
}
// Demonstration: same id, different state — entities are still equal
var sharedId = new CustomerId(java.util.UUID.randomUUID()); // => one UUID shared by both
Customer c1 = new Customer(sharedId, "Alice", new EmailAddress("a@b.com")); // => expression
Customer c2 = new Customer(sharedId, "Alice Smith", new EmailAddress("asmith@b.com")); // => expression
// => c1 and c2 have different names and emails
boolean equal = c1.equals(c2); // => true — same id; state differences are irrelevantKotlin:
// Kotlin: override equals() manually; do NOT use data class for entities
// => data class compares all fields — that is Value Object semantics, not Entity semantics
class Customer( // => class Customer
val id: CustomerId, // => identity; val: immutable
var name: String, // => mutable state; var
var email: EmailAddress // => mutable state; var
) { // => expression
override fun equals(other: Any?): Boolean { // => equals method
if (other !is Customer) return false // => precondition check
return id == other.id // => id equality only; name and email excluded
}
override fun hashCode() = id.hashCode() // => consistent with equals
}
val sharedId = CustomerId(java.util.UUID.randomUUID()) // => one UUID
val c1 = Customer(sharedId, "Alice", EmailAddress("a@b.com")) // => c1 initialised
val c2 = Customer(sharedId, "Alice Smith", EmailAddress("asmith@b.com")) // => c2 initialised
println(c1 == c2) // => true — same id; different names; identity winsC#:
public class Customer // => class Customer
// => begins block
{
public CustomerId Id { get; } // => identity; immutable
public string Name { get; private set; } // => mutable state
public EmailAddress Email { get; private set; } // => mutable state
public Customer(CustomerId id, string name, EmailAddress email) // => Customer method
// => begins block
{
Id = id; // => identity locked in constructor
Name = name; // => initial state
Email = email; // => initial state
// => ends block
}
// Equality by id ONLY — name and email excluded from comparison
public override bool Equals(object? obj) => obj is Customer c && Id == c.Id; // => Equals method
public override int GetHashCode() => Id.GetHashCode(); // => id-based hash
public void Rename(string newName) { Name = newName; } // => state change; equality unchanged
}
var sharedId = CustomerId.Generate(); // => one id shared by both instances
var c1 = new Customer(sharedId, "Alice", new EmailAddress("a@b.com")); // => c1 initialised
var c2 = new Customer(sharedId, "Alice Smith", new EmailAddress("asmith@b.com")); // => c2 initialised
bool equal = c1.Equals(c2); // => true — same id; Name/Email differences ignoredKey Takeaway: Entity equality checks identity only. Renaming a customer does not create a new customer — it updates the same entity. Equality must reflect this.
Why It Matters: If entity equality compared all fields, renaming a customer would cause it to "disappear" from hash sets and dictionary lookups — a catastrophic bug in any system that tracks entities by reference. The entity does not become a different entity when its data changes; only its identity defines sameness. Identity-only equality keeps entity semantics stable across all state transitions and ensures aggregation operations behave correctly.
Example 8: Entity mutable state — only via behaviour methods
Entity state must change only through named behaviour methods, never through public setters. Named methods communicate intent, enforce invariants, and can emit domain events.
Java:
public class Order { // => Order field
private final OrderId id; // => identity: immutable
private Money total; // => state: mutable, but only via methods below
private OrderStatus status; // => state: mutable, but only via methods below
public Order(OrderId id, Money initialTotal) { // => Order method
this.id = id; // => this.id assigned
this.total = initialTotal; // => this.total assigned
this.status = OrderStatus.PENDING; // => always starts PENDING
}
// Named behaviour: communicates intent and enforces lifecycle rule
public void confirm() { // => confirm method
// => Guard: confirm() only valid from PENDING state
if (status != OrderStatus.PENDING) // => precondition check
throw new IllegalStateException("Cannot confirm order in state: " + status); // => throws if guard fails
this.status = OrderStatus.CONFIRMED; // => state transition enforced here
// => domain event could be collected here (see Example 25)
}
public void cancel() { // => cancel method
// => Cannot cancel a shipped order — domain invariant enforced in method
if (status == OrderStatus.SHIPPED) // => precondition check
throw new IllegalStateException("Cannot cancel a shipped order"); // => throws if guard fails
this.status = OrderStatus.CANCELLED; // => only reachable if not SHIPPED
}
// No setStatus() — callers cannot bypass lifecycle rules
public OrderId getId() { return id; } // => getId method
public OrderStatus getStatus() { return status; } // => getStatus method
public Money getTotal() { return total; } // => getTotal method
}
Order order = new Order(OrderId.generate(), new Money(new java.math.BigDecimal("50"), "USD")); // => OrderId.generate() called
order.confirm(); // => status becomes CONFIRMED; invariant checked
// order.setStatus(SHIPPED) — does not exist; cannot bypass confirm()Kotlin:
class Order(val id: OrderId, initialTotal: Money) { // => class Order
var total: Money = initialTotal // => expression
private set // => readable externally; writable only inside class
var status: OrderStatus = OrderStatus.PENDING // => expression
private set // => no public setter; only behaviour methods can mutate
fun confirm() { // => confirm method
// => Enforce lifecycle: PENDING → CONFIRMED only
check(status == OrderStatus.PENDING) { "Cannot confirm order in state: $status" } // => precondition check
status = OrderStatus.CONFIRMED // => private set allows mutation from within
}
fun cancel() { // => cancel method
// => Enforce lifecycle: cannot cancel SHIPPED
check(status != OrderStatus.SHIPPED) { "Cannot cancel a shipped order" } // => precondition check
status = OrderStatus.CANCELLED // => reached only if check() passes
}
// No setStatus — callers use confirm()/cancel(); invariants always enforced
}
val order = Order(OrderId.generate(), Money(java.math.BigDecimal("50"), "USD")) // => order initialised
order.confirm() // => status = CONFIRMED; invariant checked inside confirm()C#:
public class Order // => class Order
// => Entity class: uses reference equality by default; Equals overridden to use Id
{
public OrderId Id { get; } // => Id field
// => get-only: Id locked at construction; identity never changes
public Money Total { get; private set; } // => private set; mutable via methods
// => Mutable state: Total changes as lines are added; private set enforces encapsulation
public OrderStatus Status { get; private set; } = OrderStatus.Pending; // => Status field
// => defaults to Pending; private set prevents external mutation
public Order(OrderId id, Money initialTotal) { Id = id; Total = initialTotal; } // => Order method
// => Constructor: sets both required fields; Status defaults to Pending automatically
public void Confirm() // => Confirm method
// => Named method: communicates intent; can add events, logging, metrics here
{
// => Guard: only PENDING orders can be confirmed
if (Status != OrderStatus.Pending) // => precondition check
throw new InvalidOperationException($"Cannot confirm order in state {Status}"); // => throws if guard fails
// => $"...{Status}" includes current status in the message for diagnostics
Status = OrderStatus.Confirmed; // => private set accessible within the class
// => Transition happens after guard; no partial state possible
}
public void Cancel() // => Cancel method
// => Named method: one place to add "why was this cancelled?" auditing in future
{
// => Guard: SHIPPED orders cannot be cancelled
if (Status == OrderStatus.Shipped) // => precondition check
throw new InvalidOperationException("Cannot cancel a shipped order"); // => throws if guard fails
// => All other statuses (Pending, Confirmed) are cancellable
Status = OrderStatus.Cancelled; // => reachable only if not SHIPPED
}
// No SetStatus() — public API is confirm/cancel; invariants always run
}
var order = new Order(OrderId.Generate(), new Money(50m, "USD")); // => order initialised
// => order.Status = Pending (default); order.Total = 50 USD
order.Confirm(); // => Status = Confirmed; lifecycle rule enforcedKey Takeaway: State mutation belongs in named behaviour methods, not setters. A method named confirm() communicates intent, enforces invariants, and can emit domain events — a setStatus() method does none of these.
Why It Matters: Anemic models with public setters let any caller mutate state in any order, making it impossible to guarantee invariants. When an order skips CONFIRMED and jumps straight to SHIPPED via a raw setter, the bug is invisible until a downstream process fails. Named behaviour methods eliminate this entire category of invariant violation.
Example 9: Entity vs Value Object — when to choose
The decision between Entity and Value Object determines whether two instances with the same data are the same thing or just equivalent. The wrong choice leads to either lost identity or unnecessary identity tracking.
%% Palette: Blue #0173B2, Teal #029E73, Orange #DE8F05
graph LR
A{"Same data\n= same thing?"}:::blue
B["Value Object\nStructural equality\nNo ID needed"]:::teal
C["Entity\nIdentity equality\nID field required"]:::orange
A -->|"Yes — interchangeable"| B
A -->|"No — tracked over time"| C
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
Java:
// Value Object: two instances with same data ARE the same thing
// => Money(10, "USD") and Money(10, "USD") are interchangeable — no tracking needed
public record Money(java.math.BigDecimal amount, String currency) {} // => record Money
// => record provides structural equals/hashCode; correct for VO
// Entity: two instances with same data are NOT necessarily the same thing
// => Two bank accounts at $0 balance are different accounts — track by id
public class BankAccount { // => BankAccount field
private final AccountId id; // => identity: THIS specific account
private Money balance; // => state: can change without affecting identity
public BankAccount(AccountId id, Money initial) { // => BankAccount method
this.id = id; // => this.id assigned
this.balance = initial; // => this.balance assigned
}
// Two $0 accounts are still two different accounts — never merge them
@Override public boolean equals(Object o) { // => expression
if (!(o instanceof BankAccount a)) return false; // => precondition check
return id.equals(a.id); // => only id; balance is irrelevant for identity
}
@Override public int hashCode() { return id.hashCode(); } // => id.hashCode() called
}
// Rule of thumb: ask "if I replace this with an equal instance, does anything break?"
// => If yes → Entity (history matters) If no → Value Object (interchangeable)Kotlin:
// Value Object: data class = structural equality = correct VO choice
data class Money(val amount: java.math.BigDecimal, val currency: String)
// => data class generates equals() comparing amount and currency — correct for VO
// Entity: class = reference equality by default; override to id-based equality
class BankAccount(val id: AccountId, initialBalance: Money) {
var balance: Money = initialBalance
private set // => mutable state; but changing balance doesn't change which account
override fun equals(other: Any?) = other is BankAccount && id == other.id
override fun hashCode() = id.hashCode()
// => Two $0 accounts at same bank are still different accounts — id distinguishes them
}
// Rule of thumb in Kotlin:
// data class → Value Object (structural equality, immutable val fields)
// class → Entity (identity equality, mutable var state, override equals)C#:
// Value Object: record = structural ==; correct for VO
public record Money(decimal Amount, string Currency);
// => record == compares Amount and Currency — Money(10m,"USD") == Money(10m,"USD")
// Entity: class = reference equality by default; override to id-based
public class BankAccount
{
public AccountId Id { get; }
public Money Balance { get; private set; } // => mutable state
public BankAccount(AccountId id, Money initial) { Id = id; Balance = initial; }
// Two $0 accounts are different — id is the only equality criterion
public override bool Equals(object? obj) => obj is BankAccount a && Id == a.Id;
public override int GetHashCode() => Id.GetHashCode();
// => Changing Balance does not change which account this is
}
// Rule of thumb in C#:
// record → Value Object (structural equality provided by compiler)
// class → Entity (identity equality via manual override)Key Takeaway: Use a Value Object when replacing an instance with an equal one is harmless. Use an Entity when the instance has a history that must be tracked — when "same data" does not mean "same thing."
Why It Matters: Misclassifying a concept causes subtle, hard-to-trace bugs. Treating a bank account as a VO means two accounts with the same balance could be merged — funds disappear. Treating a currency code as an Entity means it gets a database row and an identity column for a concept that is purely a value. Getting this choice right is the first design question in any domain model.
Example 10: Smart constructor / factory method enforcing invariants
Factory methods add validation to construction and provide descriptive failure messages. They also allow returning subtypes or throwing richer domain exceptions than a raw constructor can.
Java:
// Static factory method: named, validated construction with descriptive errors
// => Factory can return subtypes, use caching, or throw domain-specific exceptions
public final class Quantity { // => Quantity field
private final int value; // => int value wrapped; always positive
private Quantity(int value) { this.value = value; } // => private: factory is the only path
public static Quantity of(int value) { // => of method
// => Named validation; message references domain concept, not technical detail
if (value <= 0) throw new IllegalArgumentException("Quantity must be positive, got: " + value); // => throws if guard fails
return new Quantity(value); // => only reachable after validation passes
}
public int getValue() { return value; } // => read-only
public Quantity add(Quantity other) { // => add method
return new Quantity(this.value + other.value); // => produces new Quantity; addition always positive
}
@Override public String toString() { return String.valueOf(value); } // => "3"
}
Quantity q = Quantity.of(3); // => Succeeds; q.getValue() = 3
// Quantity bad = Quantity.of(0); // => throws "Quantity must be positive, got: 0"
// private constructor: new Quantity(3) is compile error from outside the classKotlin:
// companion object factory: private constructor forces use of of()
class Quantity private constructor(val value: Int) { // => class Quantity
// => private constructor: no new Quantity(0) from outside
companion object { // => expression
fun of(value: Int): Quantity { // => of method
require(value > 0) { "Quantity must be positive, got: $value" } // => precondition check
// => require throws IllegalArgumentException with the lambda message
return Quantity(value) // => private ctor accessible from companion
}
}
operator fun plus(other: Quantity) = Quantity(value + other.value) // => operator overload for +
override fun toString() = value.toString() // => "3"
}
val q = Quantity.of(3) // => Succeeds; q.value = 3
// val bad = Quantity.of(0) // => throws IllegalArgumentException
// operator: Quantity.of(2) + Quantity.of(3) = Quantity(5) — idiomatic KotlinC#:
// sealed record with private constructor and static factory
public sealed record Quantity // => record Quantity
{
public int Value { get; } // => get-only; no setter; immutable after factory
private Quantity(int value) { Value = value; } // => private: factory is the one path
public static Quantity Of(int value) // => Of method
{
// => Domain-language error message; makes failure traceable to business rule
if (value <= 0) throw new ArgumentException($"Quantity must be positive, got: {value}"); // => throws if guard fails
return new Quantity(value); // => only reached after validation
}
public Quantity Add(Quantity other) => new(Value + other.Value); // => Add method
// => produces new Quantity; no mutation; sum always positive (both inputs positive)
public override string ToString() => Value.ToString(); // => "3"
}
var q = Quantity.Of(3); // => Value = 3
// var bad = Quantity.Of(0); // => throws ArgumentException
// private ctor: new Quantity(3) is a compile error outside the classKey Takeaway: A static factory method is the single validated creation path. Private constructors ensure no instance can be created without passing validation.
Why It Matters: Factory methods improve discoverability (Quantity.of(3) reads like a domain sentence), allow richer error messages tied to business rules, and centralise construction logic in one place. When a Quantity invariant changes — say, the business decides maximum quantity is 1000 — the factory is the one place to update, not every constructor call site.
Example 11: Self-encapsulation — private fields, intention-revealing accessors
Self-encapsulation means all access to fields, even within the class, goes through accessors. This enforces uniform invariant-checking and allows future behaviour to be added without changing callers.
Java:
public class Order { // => Order field
private final OrderId id; // => identity
private Money total; // => private; accessed only via getTotal()
private OrderStatus status; // => private; transitions via named methods
public Order(OrderId id) { // => Order method
this.id = id; // => this.id assigned
this.total = Money.zero("USD"); // => factory method; starts at zero
this.status = OrderStatus.PENDING; // => this.status assigned
// => ends block
}
// Intention-revealing accessor: name says what it means, not how it's stored
public Money getTotal() { return total; } // => readable; not settable
public OrderStatus getStatus() { return status; } // => readable; not settable
public OrderId getId() { return id; } // => readable; immutable
// Encapsulated mutation: logic lives here, not scattered in callers
public void addToTotal(Money extra) { // => addToTotal method
// => currency-matching enforced by Money.add(); invariant inside VO
this.total = this.total.add(extra); // => replaces total with new Money instance
}
private void transitionTo(OrderStatus next) { // => transitionTo method
// => private helper enforces allowed transitions — callers use public methods
this.status = next; // => this.status assigned
}
public void confirm() { // => confirm method
if (status != OrderStatus.PENDING) // => precondition check
throw new IllegalStateException("Only PENDING orders can be confirmed"); // => throws if guard fails
transitionTo(OrderStatus.CONFIRMED); // => private helper called internally
}
}Kotlin:
class Order(val id: OrderId) { // => class Order
// => backing properties private; exposed via getters with intention-revealing names
private var _total: Money = Money(java.math.BigDecimal.ZERO, "USD") // => method declaration
private var _status: OrderStatus = OrderStatus.PENDING // => expression
val total: Money get() = _total // => read-only public getter
val status: OrderStatus get() = _status // => read-only public getter
fun addToTotal(extra: Money) { // => addToTotal method
_total = _total.add(extra) // => encapsulated; no external mutation of _total
// => ends block
}
fun confirm() { // => confirm method
check(_status == OrderStatus.PENDING) { "Only PENDING orders can be confirmed" } // => precondition check
_status = OrderStatus.CONFIRMED // => only reachable if check passes
}
// No _total = ... from outside the class; Kotlin val getter prevents it
}C#:
public class Order // => class Order
// => begins block
{
private Money _total; // => private backing field; only mutated through AddToTotal()
private OrderStatus _status; // => private backing field; only mutated through Confirm()
public OrderId Id { get; } // => immutable; public read-only
public Money Total => _total; // => expression-bodied getter; readonly projection
public OrderStatus Status => _status; // => expression-bodied getter; readonly projection
// => No public setter for Total or Status; only domain methods may mutate backing fields
public Order(OrderId id) // => Order method
{
Id = id; // => Id assigned
// => Id locked at construction; no reassignment possible (get-only property)
_total = new Money(0m, "USD"); // => starts at zero
// => Initial total is zero; grows via AddToTotal()
_status = OrderStatus.Pending; // => _status assigned
// => All orders start Pending; lifecycle methods control transitions
}
public void AddToTotal(Money extra) // => AddToTotal method
{
_total = _total.Add(extra); // => encapsulated mutation; callers cannot bypass
// => Add returns a new Money instance; _total is reassigned, not mutated in place
}
public void Confirm() // => Confirm method
{
if (_status != OrderStatus.Pending) // => precondition check
throw new InvalidOperationException("Only Pending orders can be confirmed"); // => throws if guard fails
// => Guard: CONFIRMED → re-confirm is rejected; PENDING is the only valid source state
_status = OrderStatus.Confirmed; // => private field writable here; not via property
// => Callers reading Status property see Confirmed; they cannot set it directly
}
}Key Takeaway: Keep fields private and expose only intention-revealing accessors. This gives you one place to add logging, validation, or domain events — the accessor — without changing any caller.
Why It Matters: Self-encapsulation is the smallest unit of OOP discipline. When every field access is mediated by an accessor, the class controls its own invariants completely. Refactoring from a simple field to a computed or cached value becomes a local change, invisible to callers. This is why DDD tactical patterns work well in languages with proper encapsulation.
Example 12: Tell-don't-ask method on Entity
"Tell, don't ask" means you tell an object to do something rather than asking for its data and computing the result externally. This keeps domain logic inside the domain object where it belongs.
Java:
// Tell-don't-ask: Order computes its own total from lines — callers do not extract and sum
public class Order { // => Order field
private final OrderId id; // => id field
private final java.util.List<OrderLine> lines = new java.util.ArrayList<>(); // => method declaration
private OrderStatus status = OrderStatus.PENDING; // => status declared
public Order(OrderId id) { this.id = id; } // => Order method
// WRONG (ask): external code asks for lines, then computes total
// callers would do: order.getLines().stream().map(l -> l.price).reduce(...)
// => logic duplicated across callers; no single place to change
// RIGHT (tell): Order tells itself to compute total — logic stays inside
public Money calculateTotal() { // => calculateTotal method
return lines.stream() // => returns lines.stream()
.map(OrderLine::subtotal) // => delegate to child; each line knows its own subtotal
.reduce(Money.zero("USD"), Money::add); // => fold into running total
// => caller receives result; never sees individual lines for calculation purposes
}
public void addLine(ProductId pid, Quantity qty, Money price) { // => addLine method
lines.add(new OrderLine(OrderLineId.generate(), pid, qty, price)); // => lines.add() called
// => Order creates child; callers do not construct OrderLine directly
}
public java.util.List<OrderLine> getLines() { // => getLines method
return java.util.Collections.unmodifiableList(lines); // => returns java.util.Collections.unmodifi
// => read-only view; callers can read but not mutate the list
}
}Kotlin:
class Order(val id: OrderId) { // => class Order
private val _lines = mutableListOf<OrderLine>() // => private mutable backing list
val lines: List<OrderLine> get() = _lines.toList() // => defensive copy; caller gets snapshot
// Tell-don't-ask: Order computes total; callers do not iterate lines externally
fun calculateTotal(): Money = // => calculateTotal method
_lines.fold(Money(java.math.BigDecimal.ZERO, "USD")) { acc, line -> // => _lines.fold() called
acc.add(line.subtotal()) // => each line delegates to itself (tell-don't-ask recursively)
} // => returns total; caller never needs to know how lines are stored
fun addLine(pid: ProductId, qty: Quantity, price: Money) { // => addLine method
_lines += OrderLine(OrderLineId.generate(), pid, qty, price) // => _lines assigned
// => Order owns child creation; caller provides data, Order creates the child
}
}C#:
public class Order // => class Order
{
private readonly List<OrderLine> _lines = new(); // => List method
// => private list; external code cannot manipulate it directly
public OrderId Id { get; } // => Id field
// => immutable identity
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly(); // => IReadOnlyList method
// => read-only view; callers can enumerate but not add/remove
public Order(OrderId id) { Id = id; } // => Order method
// Tell-don't-ask: ask Order for its total; don't ask for lines to compute it yourself
public Money CalculateTotal() => // => CalculateTotal method
_lines.Aggregate(new Money(0m, "USD"), (acc, line) => acc.Add(line.Subtotal())); // => _lines.Aggregate() called
// => LINQ Aggregate folds lines into total; caller gets Money back; no line details needed
public void AddLine(ProductId pid, Quantity qty, Money price) // => AddLine method
{
_lines.Add(new OrderLine(OrderLineId.Generate(), pid, qty, price)); // => _lines.Add() called
// => Order creates child; callers call AddLine with data, not with an OrderLine object
}
}Key Takeaway: Tell objects to do things rather than asking for their internals. order.calculateTotal() keeps total-calculation logic inside Order; asking for lines and summing externally spreads that logic across callers.
Why It Matters: "Ask" style scatters business logic across the codebase. When the business changes how totals are computed (add tax, apply discount), you hunt through every caller. "Tell" style localises that change to the entity's method. Entities become self-contained units of behaviour, not passive data bags, which is the core promise of DDD's rich domain model.
Strongly-Typed IDs and Enums (Examples 13–15)
Example 13: Domain primitive — typed wrapper around String for EmailAddress
A domain primitive wraps a single primitive value with validation and domain-specific meaning. EmailAddress wraps String but is not just a string — it carries the guarantee that its value is a valid email.
Java:
import java.util.Objects; // => namespace/package import
// Domain primitive: wraps String; adds domain meaning and validation
// => EmailAddress is not interchangeable with any String — type system enforces this
public final class EmailAddress { // => EmailAddress field
private final String value; // => normalised lowercase value
public EmailAddress(String value) { // => EmailAddress method
// => validate format; reject null and malformed emails at construction
if (value == null || !value.contains("@") || value.length() < 3) // => precondition check
throw new IllegalArgumentException("Invalid email: " + value); // => throws if guard fails
this.value = value.toLowerCase(); // => normalise; consistent equality
}
public String getValue() { return value; } // => read-only accessor
@Override public boolean equals(Object o) { // => expression
if (!(o instanceof EmailAddress e)) return false; // => precondition check
return value.equals(e.value); // => structural equality
}
@Override public int hashCode() { return Objects.hash(value); } // => Objects.hash() called
@Override public String toString() { return value; } // => "alice@example.com"
}
// Usage: type system prevents passing a raw String where EmailAddress expected
// void sendWelcome(EmailAddress email) — compiler rejects sendWelcome("raw string")
EmailAddress email = new EmailAddress("Alice@Example.com"); // => "alice@example.com"
// sendWelcome(email) — correct; sendWelcome("alice@example.com") — compile errorKotlin:
// @JvmInline value class: zero-overhead wrapper; erased to String at runtime
@JvmInline value class EmailAddress(val value: String) {
// => value class: no allocation overhead; wrapped value stored directly
init {
require(value.isNotBlank() && value.contains("@")) { "Invalid email: $value" }
// => init block runs at construction; validation always applies
}
}
// Usage: function signature is self-documenting and type-safe
fun sendWelcome(email: EmailAddress) { /* ... */ }
val email = EmailAddress("alice@example.com") // => validated at creation
// sendWelcome(email) — correct
// sendWelcome("alice@example.com") — compile error: String is not EmailAddressC#:
// readonly record struct: value semantics, zero heap allocation, structural equality
public readonly record struct EmailAddress // => record struct
{
public string Value { get; } // => get-only; immutable after construction
public EmailAddress(string value) // => EmailAddress method
{
// => validate; reject null/blank/missing @
if (string.IsNullOrWhiteSpace(value) || !value.Contains('@')) // => precondition check
throw new ArgumentException($"Invalid email: {value}"); // => throws if guard fails
Value = value.ToLowerInvariant(); // => normalise for consistent equality
}
public override string ToString() => Value; // => "alice@example.com"
}
// Function accepts EmailAddress, not string — compiler enforces at call site
void SendWelcome(EmailAddress email) { /* ... */ } // => expression
var email = new EmailAddress("Alice@Example.com"); // => Value = "alice@example.com"
// SendWelcome(email) — correct
// SendWelcome("alice@example.com") — compile error: string ≠ EmailAddressKey Takeaway: A domain primitive wraps a single value with validation and domain meaning. The type system enforces that you cannot accidentally pass a raw string where an EmailAddress is required.
Why It Matters: "Primitive obsession" — using raw String, int, double for domain concepts — is one of the most common sources of bugs. When methods accept String email, any string compiles. When they accept EmailAddress, invalid emails are caught at construction time, and id mix-ups (passing a product id where a customer id is expected) become compile errors rather than runtime surprises.
Example 14: Strongly-typed IDs — OrderId ≠ CustomerId at compile time
Separate typed IDs prevent passing an OrderId where a CustomerId is expected. This is a zero-cost correctness guarantee — the types are erased at runtime but enforced at compile time.
Java:
import java.util.UUID;
// Separate record types for each aggregate's id
// => OrderId and CustomerId are structurally identical but type-incompatible
public record OrderId(UUID value) {
public static OrderId generate() { return new OrderId(UUID.randomUUID()); }
// => factory: ensures every id is a real UUID
}
public record CustomerId(UUID value) {
public static CustomerId generate() { return new CustomerId(UUID.randomUUID()); }
// => same UUID internally, but separate type from OrderId
}
// Usage: method signatures prevent id mix-up at compile time
public Order findOrder(OrderId id) { /* ... */ return null; } // => accepts only OrderId
// findOrder(new CustomerId(UUID.randomUUID())) — compile error: CustomerId ≠ OrderId
// findOrder(new OrderId(UUID.randomUUID())) — correct
// Before typed ids: findOrder(UUID id) accepts any UUID — wrong ids compile silentlyKotlin:
// @JvmInline value classes: zero-overhead; erased to UUID at runtime; distinct types at compile time
@JvmInline value class OrderId(val value: java.util.UUID) { // => class OrderId
companion object { fun generate() = OrderId(java.util.UUID.randomUUID()) } // => UUID.randomUUID() called
// => generate() is the canonical factory
}
@JvmInline value class CustomerId(val value: java.util.UUID) { // => class CustomerId
companion object { fun generate() = CustomerId(java.util.UUID.randomUUID()) } // => UUID.randomUUID() called
// => same UUID type inside, but CustomerId ≠ OrderId at the Kotlin type level
}
fun findOrder(id: OrderId): Order? = null // => only OrderId accepted
// findOrder(CustomerId.generate()) — compile error
// findOrder(OrderId.generate()) — correct; no runtime cost from @JvmInlineC#:
// readonly record struct: value semantics, no heap allocation, type-distinct ids
public readonly record struct OrderId(Guid Value) // => record struct
// => begins block
{
public static OrderId Generate() => new(Guid.NewGuid()); // => canonical factory
}
public readonly record struct CustomerId(Guid Value) // => record struct
{
public static CustomerId Generate() => new(Guid.NewGuid()); // => separate type
}
Order? FindOrder(OrderId id) => null; // => only OrderId compiles here
// FindOrder(CustomerId.Generate()) — compile error: CustomerId ≠ OrderId
// FindOrder(OrderId.Generate()) — correct; record struct = zero allocationKey Takeaway: Give each aggregate its own ID type. The compiler then prevents every cross-aggregate ID mix-up for free — no runtime cost, no extra tests needed.
Why It Matters: UUID/string mix-ups — passing an order ID to a customer lookup — are notoriously hard to catch in tests because both IDs are valid GUIDs. They surface in production as "customer not found" or corrupted associations. Strongly-typed IDs convert runtime surprises into compile errors. The refactor from UUID to OrderId is mechanical and pays for itself the first time a swapped-id bug is prevented.
Example 15: Enum as domain concept — OrderStatus
An enum elevates a set of named states to a first-class type. OrderStatus is clearer, more safe, and more maintainable than using raw strings or integers for lifecycle states.
Java:
// Enum as domain concept: exhaustive; type-safe; no invalid status possible
// => Cannot pass "SHPIED" (typo) — only enum constants compile
public enum OrderStatus {
PENDING, // => Order created; awaiting confirmation
CONFIRMED, // => Payment authorised; awaiting shipment
SHIPPED, // => Physical goods dispatched
CANCELLED; // => Order cancelled before or after confirmation
// Domain behaviour inside the enum: isTerminal tells callers which states end the lifecycle
public boolean isTerminal() {
return this == SHIPPED || this == CANCELLED;
// => PENDING and CONFIRMED are interim; SHIPPED and CANCELLED are final
}
}
// Usage: switch is exhaustive when all cases are handled
OrderStatus s = OrderStatus.PENDING;
boolean terminal = s.isTerminal(); // => false — PENDING is not terminal
// String-based equivalent: switch("PENDNG") compiles — typo not caught; enum prevents thisKotlin:
// Kotlin sealed class or enum; enum is simpler for fixed finite states
enum class OrderStatus { // => enum class
PENDING, // => created; awaiting confirmation
CONFIRMED, // => payment authorised
SHIPPED, // => dispatched
CANCELLED; // => terminal; no further transitions
fun isTerminal() = this == SHIPPED || this == CANCELLED // => isTerminal method
// => business rule embedded in enum; callers ask status.isTerminal() — tell-don't-ask
}
// Kotlin when is exhaustive on enum — compiler warns on missing cases
fun describe(s: OrderStatus) = when (s) { // => describe method
OrderStatus.PENDING -> "Awaiting confirmation" // => all cases covered
OrderStatus.CONFIRMED -> "Ready to ship" // => expression
OrderStatus.SHIPPED -> "On its way" // => expression
OrderStatus.CANCELLED -> "Order cancelled" // => expression
} // => no else needed; missing case = compile warningC#:
// C# enum: strongly typed; switch expressions can be exhaustive with default handling
public enum OrderStatus // => enum OrderStatus
{
Pending = 1, // => start at 1; avoids default(int) = 0 acting as Pending silently
Confirmed = 2, // => payment confirmed
Shipped = 3, // => dispatched
Cancelled = 4 // => terminal
}
// Extension method: domain behaviour near the enum
public static class OrderStatusExtensions // => class OrderStatusExtensions
{
public static bool IsTerminal(this OrderStatus s) => // => IsTerminal method
s is OrderStatus.Shipped or OrderStatus.Cancelled; // => expression
// => C# 9 pattern: readable OR pattern in is-expression
}
// Usage
var s = OrderStatus.Pending; // => s initialised
bool terminal = s.IsTerminal(); // => false
// switch expression enforces handling: compiler warning if a case is missingKey Takeaway: Represent domain lifecycle states with enums, not strings or integers. The type system then prevents invalid states at compile time, and behaviour can be embedded directly in the enum.
Why It Matters: String-based status fields are a common source of bugs: typos compile, comparisons are case-sensitive, and the set of valid states is invisible to the type checker. Enum-based status makes the complete state space explicit, enables exhaustive switch expressions, and allows domain behaviour — like isTerminal() — to live right next to the states it describes.
Aggregates and Repositories (Examples 16–21)
Example 16: Aggregate — single root + child entities
An Aggregate is a cluster of domain objects treated as a single unit for data changes. The Aggregate Root controls all access to the cluster — external code can only reach child entities through the root.
%% Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
A["Order #40;Aggregate Root#41;"]:::blue
B["OrderLine #40;Child Entity#41;"]:::orange
C["OrderLine #40;Child Entity#41;"]:::orange
D["ShippingAddress #40;Value Object#41;"]:::teal
A --> B
A --> C
A --> D
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
Java:
import java.util.*; // => List, ArrayList, Collections — all standard library
// Aggregate Root: sole entry point into the Order cluster
// => No external code directly constructs or modifies OrderLine
public class Order { // => class not record: mutable state; identity-based equality
private final OrderId id; // => final: identity locked
private final List<OrderLine> lines = new ArrayList<>(); // => private; root owns children
private Address shippingAddress; // => Value Object child; replaceable
private OrderStatus status = OrderStatus.PENDING; // => starts PENDING; transitions via methods
public Order(OrderId id, Address shippingAddress) { // => Order method
this.id = id; // => identity locked in constructor
this.shippingAddress = shippingAddress; // => initial Value Object; may be updated
}
// External code adds lines through this method — never by creating OrderLine directly
public void addLine(ProductId productId, Quantity quantity, Money unitPrice) { // => addLine method
// => Root enforces invariant before creating child
if (status != OrderStatus.PENDING) // => lifecycle gate: only PENDING accepts new lines
throw new IllegalStateException("Cannot add lines to non-pending order"); // => domain rule as exception
OrderLineId lineId = OrderLineId.generate(); // => Root generates child's identity
lines.add(new OrderLine(lineId, productId, quantity, unitPrice)); // => Root creates and owns child
}
public Money calculateTotal() { // => tell-don't-ask: caller gets total; never iterates lines
return lines.stream().map(OrderLine::subtotal) // => each line computes its subtotal
.reduce(Money.zero("USD"), Money::add); // => fold: accumulate into running total
}
public List<OrderLine> getLines() { return Collections.unmodifiableList(lines); } // => getLines method
// => unmodifiable view: callers can read but not mutate the list
public OrderId getId() { return id; } // => expose identity for repository lookup
}
// Package-private: external code outside the aggregate package cannot construct OrderLine
class OrderLine { // => package-private class: invisible outside domain.order package
private final OrderLineId id; // => final: line identity immutable
private final ProductId productId; // => which product
private final Quantity quantity; // => how many
private final Money unitPrice; // => price per unit at time of addition
OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice) { // => OrderLine() called
// => package-private constructor: only Order (same package) can call this
this.id = id; this.productId = productId; // => this.id assigned
this.quantity = quantity; this.unitPrice = unitPrice; // => all fields set; no setters
}
Money subtotal() { return unitPrice.multiply(quantity.getValue()); } // => unitPrice.multiply() called
// => line computes its own subtotal — tell-don't-ask within the aggregate
}Kotlin:
class Order(val id: OrderId, shippingAddress: Address) { // => class Order
private val _lines = mutableListOf<OrderLine>() // => private mutable list
val lines: List<OrderLine> get() = _lines.toList() // => defensive copy on each read
var status: OrderStatus = OrderStatus.PENDING // => expression
private set // => only aggregate root methods can change status
fun addLine(productId: ProductId, quantity: Quantity, unitPrice: Money) { // => addLine method
// => invariant: only PENDING orders accept new lines
check(status == OrderStatus.PENDING) { "Cannot add lines to non-pending order" } // => precondition check
_lines += OrderLine(OrderLineId.generate(), productId, quantity, unitPrice) // => _lines assigned
// => Root creates child with generated id; external code cannot create OrderLine
}
fun calculateTotal(): Money = // => calculateTotal method
_lines.fold(Money(java.math.BigDecimal.ZERO, "USD")) { acc, line -> // => _lines.fold() called
acc.add(line.subtotal()) // => delegate to child; aggregate folds results
}
}
// internal: visible only within the module; not accessible from outside the aggregate module
internal class OrderLine( // => internal: module-scoped; not in public API
val id: OrderLineId, // => identity; immutable val
val productId: ProductId, // => which product
val quantity: Quantity, // => how many
val unitPrice: Money // => price per unit at time of line creation
) { // => expression
fun subtotal(): Money = unitPrice.multiply(quantity.value) // => line computes its own subtotal
// => tell-don't-ask: caller asks for subtotal; never asks for fields to compute it
}C#:
public class Order // => class not record: mutable state; override Equals for id-based equality
// => begins block
{
private readonly List<OrderLine> _lines = new(); // => private; root controls membership
public OrderId Id { get; } // => immutable identity
public OrderStatus Status { get; private set; } = OrderStatus.Pending; // => starts Pending
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly(); // => IReadOnlyList method
// => read-only interface: callers can read, not mutate
public Order(OrderId id) { Id = id; } // => identity locked; no other constructor overload
public void AddLine(ProductId productId, Quantity quantity, Money unitPrice) // => AddLine method
// => begins block
{
// => invariant enforced before creating child
if (Status != OrderStatus.Pending) // => lifecycle gate: only Pending accepts lines
throw new InvalidOperationException("Cannot add lines to non-pending order"); // => domain rule
_lines.Add(new OrderLine(OrderLineId.Generate(), productId, quantity, unitPrice)); // => _lines.Add() called
// => Root creates and owns the child; callers only provide data, not an OrderLine instance
}
public Money CalculateTotal() => // => CalculateTotal method
_lines.Aggregate(new Money(0m, "USD"), (acc, line) => acc.Add(line.Subtotal())); // => _lines.Aggregate() called
// => fold lines into total; all line logic stays in OrderLine.Subtotal()
}
// internal: only accessible within this assembly (same as the aggregate)
internal sealed class OrderLine // => class OrderLine
{
public OrderLineId Id { get; } // => Id field
public ProductId ProductId { get; } // => ProductId field
public Quantity Quantity { get; } // => Quantity field
public Money UnitPrice { get; } // => UnitPrice field
internal OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice) // => OrderLine method
{
Id = id; ProductId = productId; Quantity = quantity; UnitPrice = unitPrice; // => Id assigned
// => internal constructor: only Order (same assembly) can create OrderLine
}
internal Money Subtotal() => UnitPrice.Multiply(Quantity.Value); // => line computes itself
}Key Takeaway: The Aggregate Root is the only entry point into the cluster. External code accesses children only through root methods — never directly.
Why It Matters: Direct access to child entities bypasses the root's invariant checks. Without an Aggregate Root mediating access, any service can mutate order lines in ways that corrupt the order's total or violate lifecycle rules. Production incidents frequently trace back to a background job directly updating a child entity and breaking an invariant that the root was supposed to enforce. The aggregate boundary makes the root the single, auditable source of truth for consistency within the cluster.
Example 17: Aggregate Root enforces invariants on child changes
The Aggregate Root re-checks cross-child invariants whenever the cluster changes. Single-entity invariants live in the entity; cross-entity invariants live in the root.
%% Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Brown #CA9161
flowchart LR
E["External caller"]:::brown
R["Order Root\n#40;Aggregate Root#41;"]:::blue
I{"Invariant\ncheck"}:::orange
C["OrderLine\n#40;Child Entity#41;"]:::teal
ERR["Exception\n#40;invariant violated#41;"]:::brown
E -->|"addLine#40;...#41;"| R
R --> I
I -->|"pass"| C
I -->|"fail"| ERR
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef brown fill:#CA9161,stroke:#000,color:#fff,stroke-width:2px
Java:
public class Order { // => Aggregate Root; owns invariants for the whole cluster
private static final int MAX_LINES = 50; // => business rule: orders capped at 50 lines
private final OrderId id; // => identity; final
private final List<OrderLine> lines = new ArrayList<>(); // => mutable child collection
private OrderStatus status = OrderStatus.PENDING; // => lifecycle state
public Order(OrderId id) { this.id = id; } // => minimal constructor; no other arg needed here
public void addLine(ProductId productId, Quantity quantity, Money unitPrice) { // => addLine method
// => Cross-child invariant 1: order must be in a state that accepts new lines
if (status != OrderStatus.PENDING) // => status check before any mutation
throw new IllegalStateException("Non-pending order cannot accept new lines"); // => domain rule
// => Cross-child invariant 2: cannot exceed maximum line count
if (lines.size() >= MAX_LINES) // => size check before adding
throw new IllegalStateException("Order cannot exceed " + MAX_LINES + " lines"); // => domain cap
lines.add(new OrderLine(OrderLineId.generate(), productId, quantity, unitPrice)); // => lines.add() called
// => Child added only after both invariants pass; cluster stays consistent
}
public void confirm() { // => confirm method
// => Cross-child invariant: cannot confirm empty order
if (lines.isEmpty()) throw new IllegalStateException("Cannot confirm order with no lines"); // => throws if guard fails
if (status != OrderStatus.PENDING) throw new IllegalStateException("Only PENDING orders can be confirmed"); // => throws if guard fails
status = OrderStatus.CONFIRMED; // => all invariants passed; transition is safe
}
}Kotlin:
class Order(val id: OrderId) {
// => Primary constructor: id is a val property; immutable after construction
companion object { const val MAX_LINES = 50 } // => named constant; domain rule is visible
// => companion object: Kotlin's static scope; MAX_LINES accessible as Order.MAX_LINES
private val _lines = mutableListOf<OrderLine>()
// => Private mutable list; backing storage for the child entity collection
var status: OrderStatus = OrderStatus.PENDING; private set
// => Starts PENDING; private set: callers can read but not directly assign status
fun addLine(productId: ProductId, quantity: Quantity, unitPrice: Money) {
// => Invariant 1: lifecycle gate
check(status == OrderStatus.PENDING) { "Non-pending order cannot accept new lines" }
// => check() throws IllegalStateException if status is not PENDING
// => Invariant 2: size limit — cross-child rule owned by root
check(_lines.size < MAX_LINES) { "Order cannot exceed $MAX_LINES lines" }
// => Size check: _lines.size returns current count; < MAX_LINES is the constraint
_lines += OrderLine(OrderLineId.generate(), productId, quantity, unitPrice)
// => Both checks passed; child safely added
// => += is shorthand for _lines.add(); new OrderLine created with generated id
}
fun confirm() {
// => Cross-child invariant: at least one line required
check(_lines.isNotEmpty()) { "Cannot confirm order with no lines" }
// => isNotEmpty(): returns true if _lines has at least one element
check(status == OrderStatus.PENDING) { "Only PENDING orders can be confirmed" }
// => Second guard: status must still be PENDING when confirm() is called
status = OrderStatus.CONFIRMED
// => private set accessible within Order; external code cannot set this directly
}
}C#:
public class Order // => class Order
{
private const int MaxLines = 50; // => named constant; documents the business rule
private readonly List<OrderLine> _lines = new(); // => List method
// => Private mutable backing list; external code gets read-only view only
public OrderId Id { get; } // => Id field
// => Identity: set in constructor, never reassigned
public OrderStatus Status { get; private set; } = OrderStatus.Pending; // => Status field
// => Starts Pending; private set ensures only Order methods can transition status
public Order(OrderId id) { Id = id; } // => Order method
// => New orders start with empty line list and Pending status
public void AddLine(ProductId productId, Quantity quantity, Money unitPrice) // => AddLine method
{
// => Invariant 1: status gate before modifying the cluster
if (Status != OrderStatus.Pending) // => precondition check
throw new InvalidOperationException("Non-pending order cannot accept new lines"); // => throws if guard fails
// => Confirmed or shipped orders cannot grow; only Pending accepts new lines
// => Invariant 2: size limit enforced by root; no individual line knows about this
if (_lines.Count >= MaxLines) // => precondition check
throw new InvalidOperationException($"Order cannot exceed {MaxLines} lines"); // => throws if guard fails
// => 50 is the business rule; MaxLines documents why, not just what
_lines.Add(new OrderLine(OrderLineId.Generate(), productId, quantity, unitPrice)); // => _lines.Add() called
// => OrderLine created by root with a new generated id; caller never constructs OrderLine directly
}
public void Confirm() // => Confirm method
{
// => Cross-child invariant: non-empty order required before confirmation
if (_lines.Count == 0) throw new InvalidOperationException("Cannot confirm empty order"); // => throws if guard fails
// => Root checks aggregate-level invariant: one or more lines required
if (Status != OrderStatus.Pending) throw new InvalidOperationException("Only Pending orders can be confirmed"); // => throws if guard fails
// => Lifecycle guard: only Pending → Confirmed is allowed here
Status = OrderStatus.Confirmed; // => all invariants satisfied; transition safe
}
}Key Takeaway: Cross-child invariants live in the Aggregate Root. Individual child invariants live in the child. Each layer enforces only what it can see.
Why It Matters: A 51st order line or confirming an empty order are invariants that no single OrderLine can enforce because they involve the entire cluster's state. Centralising these rules in the root ensures they fire consistently regardless of which code path triggers the change — whether it is a REST endpoint, a background import job, or an admin CLI. Without this centralisation, each entry point must duplicate the rule, and a missed copy causes a production invariant violation.
Example 18: Aggregate boundary — never expose mutable children
Exposing mutable child entities directly lets external code bypass Aggregate Root invariant checks. The root must return read-only views or immutable snapshots.
%% Palette: Blue #0173B2, Teal #029E73, Orange #DE8F05, Brown #CA9161
graph TD
EXT["External Code"]:::brown
ROOT["Order Root"]:::blue
RO["unmodifiableList\n#40;read-only view#41;"]:::teal
LINES["List#60;OrderLine#62;\n#40;mutable internals#41;"]:::orange
EXT -->|"getLines#40;#41;"| ROOT
ROOT --> RO
RO -.->|"view only"| LINES
EXT -.-x|"cannot reach directly"| LINES
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef brown fill:#CA9161,stroke:#000,color:#fff,stroke-width:2px
Java:
import java.util.*; // => namespace/package import
public class Order { // => Order field
private final List<OrderLine> lines = new ArrayList<>(); // => mutable internal list
private OrderStatus status = OrderStatus.PENDING; // => status declared
// ...
// WRONG: returns mutable list — caller can add/remove lines without root's knowledge
// public List<OrderLine> getLines() { return lines; } // => anti-pattern; never do this
// CORRECT: returns read-only view — caller can iterate, never mutate
public List<OrderLine> getLines() { // => getLines method
return Collections.unmodifiableList(lines); // => returns Collections.unmodifiableList(l
// => UnsupportedOperationException if caller tries lines.add(...) or lines.remove(...)
}
// ALSO CORRECT: return defensive copy — caller gets a snapshot; no view into internals
public List<OrderLine> getLinesSnapshot() { // => getLinesSnapshot method
return new ArrayList<>(lines); // => copy; caller mutations don't affect the original
}
// External add goes through root — invariants enforced
public void addLine(ProductId pid, Quantity qty, Money price) { // => addLine method
if (status != OrderStatus.PENDING) throw new IllegalStateException("Order not pending"); // => throws if guard fails
lines.add(new OrderLine(OrderLineId.generate(), pid, qty, price)); // => lines.add() called
// => only reachable after invariant check; aggregate stays consistent
}
}Kotlin:
class Order(val id: OrderId) { // => class Order
private val _lines = mutableListOf<OrderLine>() // => private mutable; internal
var status: OrderStatus = OrderStatus.PENDING; private set // => expression
// Exposes read-only List interface — caller cannot call add/remove
val lines: List<OrderLine> get() = _lines.toList() // => defensive copy each time
// => caller gets a snapshot; mutations to their copy don't affect _lines
fun addLine(pid: ProductId, qty: Quantity, price: Money) { // => addLine method
check(status == OrderStatus.PENDING) { "Order not pending" } // => precondition check
_lines += OrderLine(OrderLineId.generate(), pid, qty, price) // => _lines assigned
// => only through this method; invariant always runs
}
}C#:
public class Order // => class Order
{
private readonly List<OrderLine> _lines = new(); // => private; root owns this
public OrderStatus Status { get; private set; } = OrderStatus.Pending; // => Status field
// IReadOnlyList<T>: exposes Count and indexer but no Add/Remove/Clear
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly(); // => IReadOnlyList method
// => AsReadOnly() wraps _lines; changes to _lines are visible but callers cannot mutate
// Alternatively: return an IEnumerable<OrderLine> for even more encapsulation
// public IEnumerable<OrderLine> Lines => _lines; // => read-only; no Count without LINQ
public void AddLine(ProductId pid, Quantity qty, Money price) // => AddLine method
{
if (Status != OrderStatus.Pending) // => precondition check
throw new InvalidOperationException("Order not pending"); // => throws if guard fails
_lines.Add(new OrderLine(OrderLineId.Generate(), pid, qty, price)); // => _lines.Add() called
// => invariant checked; child added through the root only
}
}Key Takeaway: Return read-only views or defensive copies of child collections. Never return the mutable internal list — doing so hands a bypass key to every caller.
Why It Matters: When external code can add lines directly to the backing list, it bypasses the root's invariant checks — maximum line count, duplicate detection, and status-gating are all skippable with a single list reference. The aggregate boundary becomes meaningless in that case. Read-only views make the enforcement contract physical: the type system prevents the bypass, not documentation or convention, and no code review discipline can be as reliable as a compile-time error.
Example 19: Aggregate as transactional boundary
One transaction should change at most one aggregate. Cross-aggregate eventual consistency is achieved through domain events, not distributed transactions.
Java:
// Application Service: one transaction = one aggregate; see Example 23 for full service
public class ConfirmOrderService {
private final OrderRepository orderRepo; // => loads/saves Order aggregate
private final CustomerRepository customerRepo; // => separate aggregate; separate transaction
public ConfirmOrderService(OrderRepository o, CustomerRepository c) {
this.orderRepo = o;
this.customerRepo = c;
}
// WRONG: two aggregates in one transaction — tight coupling, hard to scale
// public void confirmAndUpdateCustomer(OrderId oid, CustomerId cid) {
// Order o = orderRepo.findById(oid); // => first aggregate loaded
// Customer c = customerRepo.findById(cid); // => second aggregate loaded
// o.confirm();
// c.recordPurchase(o.getTotal()); // => BOTH saved in one transaction — fragile
// }
// CORRECT: confirm only touches Order; Customer updated via domain event (see Example 25)
public void confirm(OrderId orderId) {
Order order = orderRepo.findById(orderId) // => loads one aggregate
.orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));
order.confirm(); // => domain logic on one aggregate
orderRepo.save(order); // => one save; one transaction boundary
// => OrderConfirmed event collected inside order; published after save (Example 25)
}
}Kotlin:
class ConfirmOrderService( // => class ConfirmOrderService
private val orderRepo: OrderRepository, // => Order aggregate repository
private val customerRepo: CustomerRepository // => Customer aggregate repository — separate
) { // => expression
fun confirm(orderId: OrderId) { // => confirm method
// => One transaction touches one aggregate only
val order = orderRepo.findById(orderId) ?: throw IllegalArgumentException("Not found: $orderId") // => order initialised
order.confirm() // => domain logic; event collected inside
orderRepo.save(order) // => persist; transaction commits here
// => CustomerRepository not touched in this transaction; Customer updated via event
}
}C#:
public class ConfirmOrderService // => class ConfirmOrderService
// => Application Service: orchestrates one use case per public method
{
private readonly IOrderRepository _orderRepo; // => orderRepo field
// => Interface dependency: domain code never imports EF Core or SQL directly
private readonly ICustomerRepository _customerRepo; // => separate aggregate; NOT used here
// => Injected to illustrate that Customer is NOT touched in this transaction
public ConfirmOrderService(IOrderRepository orders, ICustomerRepository customers) // => ConfirmOrderService method
// => Constructor injection: dependencies provided at registration time
{
_orderRepo = orders; // => _orderRepo assigned
// => Stored; used in Confirm() to load and save the Order
_customerRepo = customers; // => injected but not used in this transaction
// => Stored; used by other methods that need customer access
}
public void Confirm(OrderId orderId) // => Confirm method
// => Confirm use case: load Order, call domain method, persist; one aggregate, one TX
{
// => One transaction = one aggregate: only Order touched here
var order = _orderRepo.FindById(orderId) // => order initialised
?? throw new KeyNotFoundException($"Order not found: {orderId}"); // => throws if guard fails
// => FindById returns null if not found; null-coalescing throw makes that explicit
order.Confirm(); // => domain logic inside aggregate
// => Aggregate validates status and collects domain events; no persistence yet
_orderRepo.Save(order); // => persist; transaction boundary ends here
// => DB commit happens at Save(); if Confirm() threw, we never reach this line
// => Customer consistency achieved via OrderConfirmed event; not here
}
}Key Takeaway: One transaction touches one aggregate. Cross-aggregate consistency uses domain events and eventual consistency, not extending the transaction boundary.
Why It Matters: Distributed transactions spanning multiple aggregates are fragile, hard to scale, and difficult to reason about. Keeping the transaction boundary at the aggregate level makes systems resilient: if the customer-update fails, the order still exists. Eventual consistency is a business decision — and most business stakeholders accept that loyalty points may update "in a few seconds" rather than atomically with the order.
Example 20: Repository interface — collection illusion in domain layer
The Repository presents a collection-like interface to the domain, hiding all persistence details. The domain layer works with an in-memory abstraction; infrastructure implements it with a database.
%% Palette: Blue #0173B2, Teal #029E73, Orange #DE8F05, Brown #CA9161
graph TD
APP["Application Service\n#40;domain layer#41;"]:::blue
REPO["IOrderRepository\n#40;interface — domain#41;"]:::teal
IMPL["JpaOrderRepository\n#40;implementation — infra#41;"]:::orange
DB["Database"]:::brown
APP -->|"depends on interface"| REPO
IMPL -->|"implements"| REPO
IMPL -->|"queries"| DB
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef brown fill:#CA9161,stroke:#000,color:#fff,stroke-width:2px
Java:
import java.util.Optional; // => namespace/package import
// Repository interface lives in the domain layer — no infrastructure imports
// => Domain layer depends on abstraction; infrastructure implements it
public interface OrderRepository { // => OrderRepository field
Optional<Order> findById(OrderId id); // => Optional: communicates "may not exist"
void save(Order order); // => upsert semantics: insert or update
void delete(OrderId id); // => remove from collection
// => No SQL, no JPA, no database — pure domain concepts
}
// Infrastructure implementation — lives in the infra layer
// (Shown here for illustration; in production, this is in a separate package/module)
public class InMemoryOrderRepository implements OrderRepository { // => OrderRepository field
private final java.util.Map<OrderId, Order> store = new java.util.HashMap<>(); // => method declaration
// => HashMap simulates a database for tests; real impl uses JPA/JDBC
@Override public Optional<Order> findById(OrderId id) { // => expression
return Optional.ofNullable(store.get(id)); // => null → empty Optional
}
@Override public void save(Order order) { // => expression
store.put(order.getId(), order); // => insert or overwrite
}
@Override public void delete(OrderId id) { // => expression
store.remove(id); // => remove entry
}
}
// Domain service depends only on the interface — testable without a database
OrderRepository repo = new InMemoryOrderRepository(); // => inject in production
Order o = new Order(OrderId.generate(), new Address(new Street("1 Main"), new City("NYC"), PostalCode.of("10001"))); // => OrderId.generate() called
repo.save(o); // => stored
Optional<Order> found = repo.findById(o.getId()); // => found.isPresent() = trueKotlin:
import java.util.Optional // => namespace/package import
// Interface in domain layer: no database imports allowed here
interface OrderRepository { // => interface OrderRepository
fun findById(id: OrderId): Order? // => nullable return: idiomatic Kotlin optional
fun save(order: Order) // => upsert
fun delete(id: OrderId) // => remove
}
// In-memory implementation for tests and development
class InMemoryOrderRepository : OrderRepository { // => class InMemoryOrderRepository
private val store = mutableMapOf<OrderId, Order>() // => mutable map simulates persistence
override fun findById(id: OrderId) = store[id] // => null if not found
override fun save(order: Order) { store[order.id] = order } // => upsert
override fun delete(id: OrderId) { store.remove(id) } // => remove
}
val repo: OrderRepository = InMemoryOrderRepository() // => inject; swap for JPA impl in prodC#:
// Interface in domain layer: only domain types; no EF, no IQueryable
public interface IOrderRepository // => interface IOrderRepository
// => begins block
{
Order? FindById(OrderId id); // => nullable: communicates "may not exist"
void Save(Order order); // => upsert semantics
void Delete(OrderId id); // => remove
}
// In-memory implementation for tests
public sealed class InMemoryOrderRepository : IOrderRepository // => class InMemoryOrderRepository
{
private readonly Dictionary<OrderId, Order> _store = new(); // => Dictionary method
// => Dictionary simulates a database; real impl uses EF Core
public Order? FindById(OrderId id) => _store.GetValueOrDefault(id); // => null if missing
public void Save(Order order) => _store[order.Id] = order; // => insert or overwrite
public void Delete(OrderId id) => _store.Remove(id); // => remove entry
}
IOrderRepository repo = new InMemoryOrderRepository(); // => inject at composition rootKey Takeaway: The Repository interface lives in the domain layer. Infrastructure implements it. The domain never imports a database driver.
Why It Matters: When domain objects import JPA or Entity Framework, they become coupled to persistence technology. Switching from a relational database to an event store requires touching every domain class. The Repository pattern inverts this dependency — the domain defines the interface it needs; infrastructure provides the implementation. This makes domain logic testable with in-memory fakes in milliseconds, and persistence technology freely swappable without altering domain behaviour.
Example 21: Repository methods — findById / save / delete
Consistent repository method names across all aggregates create a uniform API. The team can use any repository without reading its documentation because the signatures are predictable.
Java:
import java.util.Optional;
// Consistent interface contract: every aggregate repository follows the same pattern
// => findById returns Optional; save is upsert; delete takes the id type
public interface CustomerRepository {
Optional<Customer> findById(CustomerId id); // => mirrors OrderRepository.findById pattern
void save(Customer customer); // => same upsert semantics as OrderRepository
void delete(CustomerId id); // => same delete signature; id-typed
// Optional enrichment: collection-style finders
java.util.List<Customer> findByEmail(EmailAddress email);
// => additional methods allowed; base CRUD must stay consistent
}
// Usage: predictable API reduces cognitive load
CustomerRepository repo = new InMemoryCustomerRepository(); // => any implementation
Customer customer = new Customer(CustomerId.generate(), "Alice", new EmailAddress("a@b.com"));
repo.save(customer); // => persisted
Optional<Customer> found = repo.findById(customer.getId()); // => found
repo.delete(customer.getId()); // => removed
// => Same method names as OrderRepository; no documentation neededKotlin:
// Kotlin: interface with nullable return type; consistent naming pattern
interface CustomerRepository { // => interface CustomerRepository
fun findById(id: CustomerId): Customer? // => null if absent; same as Order repo
fun save(customer: Customer) // => upsert; same as Order repo
fun delete(id: CustomerId) // => remove; same as Order repo
fun findByEmail(email: EmailAddress): List<Customer> // => optional enrichment
// => ends block
}
class InMemoryCustomerRepository : CustomerRepository { // => class InMemoryCustomerRepository
private val store = mutableMapOf<CustomerId, Customer>() // => store declared
override fun findById(id: CustomerId) = store[id] // => null if missing
override fun save(c: Customer) { store[c.id] = c } // => upsert
override fun delete(id: CustomerId) { store.remove(id) } // => remove
override fun findByEmail(email: EmailAddress) = // => findByEmail method
store.values.filter { it.email == email } // => linear scan; real impl uses index
}C#:
// Consistent interface: FindById/Save/Delete pattern matches IOrderRepository
// => Same method names across all repositories; no per-repo API to learn
public interface ICustomerRepository
{
Customer? FindById(CustomerId id); // => nullable; same pattern as IOrderRepository
// => C# nullable reference type: ? signals "may be null if not found"
void Save(Customer customer); // => upsert
// => Single method covers both insert (new id) and update (existing id)
void Delete(CustomerId id); // => remove by id
// => Takes only id; no need to load Customer first
IReadOnlyList<Customer> FindByEmail(EmailAddress email); // => optional enrichment
// => Returns IReadOnlyList: read-only; callers cannot add to the returned list
}
public sealed class InMemoryCustomerRepository : ICustomerRepository
// => sealed: not subclassable; in-memory impl is for tests and demos only
{
private readonly Dictionary<CustomerId, Customer> _store = new();
// => CustomerId key; O(1) lookups by id
public Customer? FindById(CustomerId id) => _store.GetValueOrDefault(id);
// => GetValueOrDefault: returns null if key absent; no KeyNotFoundException
public void Save(Customer c) => _store[c.Id] = c;
// => Indexer assignment: insert if new id, replace if existing id
public void Delete(CustomerId id) => _store.Remove(id);
// => Remove: no-op if id not found; idempotent deletion
public IReadOnlyList<Customer> FindByEmail(EmailAddress e) =>
_store.Values.Where(c => c.Email == e).ToList().AsReadOnly();
// => LINQ filter; real impl delegates to database index
// => AsReadOnly(): wraps mutable List<T> as IReadOnlyList<T>
}Key Takeaway: Use findById/save/delete consistently across all repository interfaces. Uniform naming reduces cognitive overhead — developers know the API before they read it.
Why It Matters: Inconsistent repository signatures — get vs load vs fetch, insert+update vs save — force developers to read each repository's source before using it, multiplying cognitive overhead across every feature. Consistent naming from a shared convention makes every repository immediately understandable and reduces onboarding time for new team members. This productivity benefit compounds in large domain models with dozens of aggregate types and multiple contributing developers.
Domain Services, Application Services, and Events (Examples 22–25)
Example 22: Domain Service — operation spanning two aggregates
Some domain operations belong to neither aggregate because they require collaboration between two. A Domain Service hosts these cross-aggregate operations while keeping them in the domain layer.
%% Palette: Blue #0173B2, Teal #029E73, Purple #CC78BC
graph LR
DS["TransferService\n#40;Domain Service#41;"]:::purple
A1["BankAccount A\n#40;Aggregate#41;"]:::blue
A2["BankAccount B\n#40;Aggregate#41;"]:::teal
DS -->|"withdraw"| A1
DS -->|"deposit"| A2
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000,color:#fff,stroke-width:2px
Java:
// Domain Service: stateless; encapsulates domain logic that spans two aggregates
// => Not application logic (no repo calls); pure domain logic with domain objects
public class TransferService { // => TransferService field
// => No fields; stateless — can be called repeatedly without side effects
public void transfer(BankAccount from, BankAccount to, Money amount) { // => transfer method
// => Both aggregates passed in; service does not load them (app service does that)
if (!from.getCurrency().equals(amount.getCurrency())) // => precondition check
throw new IllegalArgumentException("Currency mismatch between account and amount"); // => throws if guard fails
// => Domain rule enforced here: funds must cover the transfer
from.withdraw(amount); // => mutates 'from' aggregate; invariant checked inside BankAccount
to.deposit(amount); // => mutates 'to' aggregate; both aggregates now in new states
// => App service (Example 23) saves both aggregates and publishes events
}
}
// Simplified BankAccount aggregate (full aggregate has more invariants)
public class BankAccount { // => BankAccount field
private final AccountId id; // => id field
private Money balance; // => balance field
private final String currency; // => accounts are single-currency
public BankAccount(AccountId id, Money initialBalance) { // => BankAccount method
this.id = id; this.balance = initialBalance; // => this.id assigned
this.currency = initialBalance.getCurrency(); // => this.currency assigned
}
public void withdraw(Money amount) { // => withdraw method
// => Invariant: balance must not go negative
if (balance.getAmount().compareTo(amount.getAmount()) < 0) // => precondition check
throw new IllegalStateException("Insufficient funds"); // => throws if guard fails
this.balance = balance.subtract(amount); // => state change after invariant check
}
public void deposit(Money amount) { // => deposit method
this.balance = balance.add(amount); // => always valid; balance increases
}
public String getCurrency() { return currency; } // => getCurrency method
public AccountId getId() { return id; } // => getId method
}Kotlin:
// Domain Service: stateless object — uses object keyword for singleton
object TransferService { // => object TransferService
// => object: singleton; no constructor; stateless by design
fun transfer(from: BankAccount, to: BankAccount, amount: Money) { // => transfer method
// => Domain rule: currency must match
require(from.currency == amount.currency) { "Currency mismatch" } // => precondition check
from.withdraw(amount) // => aggregate enforces its own invariants
to.deposit(amount) // => aggregate enforces its own invariants
// => Application Service (outside domain) loads and saves both aggregates
// => ends block
}
}
class BankAccount(val id: AccountId, initialBalance: Money) { // => class BankAccount
var balance: Money = initialBalance; private set // => expression
val currency: String = initialBalance.currency // => locked at creation
fun withdraw(amount: Money) { // => withdraw method
require(balance.amount >= amount.amount) { "Insufficient funds" } // => precondition check
balance = balance.subtract(amount) // => private set; mutation via this method only
}
fun deposit(amount: Money) { // => deposit method
balance = balance.add(amount) // => always valid; add is positive
}
}C#:
// Domain Service: static class signals stateless; no DI needed; pure domain logic
public static class TransferService // => class TransferService
// => begins block
{
public static void Transfer(BankAccount from, BankAccount to, Money amount) // => Transfer method
// => begins block
{
// => Domain rule checked in service; both aggregates passed in by caller
if (from.Currency != amount.Currency) // => precondition check
throw new ArgumentException("Currency mismatch between account and amount"); // => throws if guard fails
from.Withdraw(amount); // => BankAccount enforces its own balance invariant
to.Deposit(amount); // => BankAccount updates its own balance
// => Application Service saves both aggregates; not this service's responsibility
// => ends block
}
// => ends block
}
public class BankAccount // => class BankAccount
{
public AccountId Id { get; } // => Id field
public Money Balance { get; private set; } // => private set; mutation via Withdraw/Deposit
public string Currency { get; } // => Currency field
public BankAccount(AccountId id, Money initial) { Id = id; Balance = initial; Currency = initial.Currency; } // => BankAccount method
public void Withdraw(Money amount) // => Withdraw method
{
// => Domain invariant: balance must cover withdrawal
if (Balance.Amount < amount.Amount) throw new InvalidOperationException("Insufficient funds"); // => throws if guard fails
Balance = Balance.Subtract(amount); // => private set accessible within class
}
public void Deposit(Money amount) { Balance = Balance.Add(amount); } // => always valid
}Key Takeaway: A Domain Service hosts domain logic that spans multiple aggregates. It is stateless, lives in the domain layer, and receives aggregates as arguments rather than loading them itself.
Why It Matters: Without Domain Services, cross-aggregate logic lands in Application Services (making them fat with business rules) or gets forced into one aggregate (creating inappropriate coupling that bleeds its boundary). A named Domain Service like TransferService makes the operation visible in the ubiquitous language and keeps domain logic squarely in the domain layer. Domain experts can point to TransferService as a named concept; they cannot point to an anonymous if-statement buried in a controller.
Example 23: Application Service — use-case orchestrator
The Application Service is the entry point for a use case. It is thin: load aggregates from repositories, call domain objects, save results, publish events. It contains no domain logic itself.
%% Palette: Blue #0173B2, Teal #029E73, Orange #DE8F05, Brown #CA9161, Purple #CC78BC
flowchart TD
CMD["PlaceOrderCommand\n#40;input DTO#41;"]:::brown
AS["PlaceOrderApplicationService\n#40;orchestrates; no domain logic#41;"]:::blue
REPO["IOrderRepository\n#40;load/save#41;"]:::teal
AGG["Order Aggregate\n#40;domain logic lives here#41;"]:::orange
PUB["DomainEventPublisher\n#40;publish events#41;"]:::purple
CMD --> AS
AS -->|"1. load"| REPO
REPO --> AGG
AS -->|"2. call placeOrder"| AGG
AS -->|"3. save"| REPO
AS -->|"4. publish events"| PUB
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef brown fill:#CA9161,stroke:#000,color:#fff,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000,color:#fff,stroke-width:2px
Java:
// Application Service: thin orchestrator; all business rules are in domain objects
// => No if-statements about business logic here; only coordination
public class PlaceOrderApplicationService { // => PlaceOrderApplicationService field
private final OrderRepository orderRepo; // => loads/saves Order aggregate
private final CustomerRepository customerRepo; // => loads Customer for validation
private final DomainEventPublisher publisher; // => dispatches collected events
public PlaceOrderApplicationService(OrderRepository orders, CustomerRepository customers, // => PlaceOrderApplicationService method
DomainEventPublisher pub) { // => expression
this.orderRepo = orders; // => this.orderRepo assigned
this.customerRepo = customers; // => this.customerRepo assigned
this.publisher = pub; // => this.publisher assigned
}
// Use case: one method = one use case
public OrderId placeOrder(CustomerId customerId, ProductId productId, Quantity quantity, Money price) { // => placeOrder method
// => Step 1: load aggregate — repository hides database details
Customer customer = customerRepo.findById(customerId) // => customerRepo.findById() called
.orElseThrow(() -> new IllegalArgumentException("Customer not found: " + customerId)); // => expression
// => Step 2: create new Order aggregate via factory
Order order = customer.startOrder(OrderId.generate()); // => customer.startOrder() called
// => Step 3: call domain behaviour — business rule is inside addLine(), not here
order.addLine(productId, quantity, price); // => order.addLine() called
// => Step 4: save — repository persists the aggregate
orderRepo.save(order); // => orderRepo.save() called
// => Step 5: publish domain events collected inside the aggregate
order.getDomainEvents().forEach(publisher::publish); // => order.getDomainEvents() called
order.clearDomainEvents(); // => prevent double-publishing
return order.getId(); // => return id to caller (controller/API layer)
}
}Kotlin:
// Application Service: coordinates; delegates all logic to domain objects
class PlaceOrderApplicationService( // => class PlaceOrderApplicationService
private val orderRepo: OrderRepository, // => Order persistence
private val customerRepo: CustomerRepository, // => Customer lookup
private val publisher: DomainEventPublisher // => event dispatch
) { // => expression
fun placeOrder(customerId: CustomerId, productId: ProductId, qty: Quantity, price: Money): OrderId { // => placeOrder method
// => 1. load Customer aggregate
val customer = customerRepo.findById(customerId) ?: throw IllegalArgumentException("Customer not found") // => customer initialised
// => 2. factory method on Customer creates Order (domain logic in Customer)
val order = customer.startOrder(OrderId.generate()) // => order initialised
// => 3. domain behaviour on Order (invariants inside Order.addLine)
order.addLine(productId, qty, price) // => order.addLine() called
// => 4. persist
orderRepo.save(order) // => orderRepo.save() called
// => 5. publish events collected inside Order
order.domainEvents.forEach { publisher.publish(it) } // => publisher.publish() called
order.clearDomainEvents() // => order.clearDomainEvents() called
return order.id // => return to caller
}
}C#:
// Application Service: thin; all business decisions delegated to aggregates and domain services
public class PlaceOrderApplicationService // => class PlaceOrderApplicationService
{
private readonly IOrderRepository _orderRepo; // => orderRepo field
private readonly ICustomerRepository _customerRepo; // => customerRepo field
private readonly IDomainEventPublisher _publisher; // => publisher field
public PlaceOrderApplicationService(IOrderRepository orders, ICustomerRepository customers, // => PlaceOrderApplicationService method
IDomainEventPublisher pub) // => expression
{
_orderRepo = orders; // => _orderRepo assigned
_customerRepo = customers; // => _customerRepo assigned
_publisher = pub; // => _publisher assigned
}
public OrderId PlaceOrder(CustomerId customerId, ProductId productId, Quantity qty, Money price) // => PlaceOrder method
{
// => 1. load Customer aggregate; throw if not found
var customer = _customerRepo.FindById(customerId) // => customer initialised
?? throw new KeyNotFoundException($"Customer not found: {customerId}"); // => throws if guard fails
// => 2. Customer aggregate creates the Order (domain logic inside StartOrder)
var order = customer.StartOrder(OrderId.Generate()); // => order initialised
// => 3. domain behaviour; business rules inside Order.AddLine
order.AddLine(productId, qty, price); // => order.AddLine() called
// => 4. persist through repository interface
_orderRepo.Save(order); // => _orderRepo.Save() called
// => 5. publish events; save first, then publish (order matters — see Example 25)
foreach (var evt in order.DomainEvents) _publisher.Publish(evt); // => iteration over collection
order.ClearDomainEvents(); // => order.ClearDomainEvents() called
return order.Id; // => return id to the calling layer
}
}Key Takeaway: An Application Service orchestrates — it loads, calls domain objects, saves, and publishes. It contains zero business rules. If you see an if that reflects a business decision in an Application Service, it belongs in a domain object.
Why It Matters: Fat Application Services are the most common DDD anti-pattern in practice. When business logic leaks from domain objects into Application Services, it becomes invisible to domain experts, is duplicated across services when a second entry point is added, and resists unit testing without spinning up infrastructure. Thin Application Services keep the domain model as the single source of business truth, testable independently of HTTP, queues, and databases.
Example 24: Domain Event — past-tense fact
A Domain Event records that something significant happened in the domain. Events are named in past tense (OrderPlaced, OrderConfirmed) because they describe facts that have already occurred and cannot be undone.
Java:
import java.time.Instant; // => namespace/package import
// Domain Event: immutable record of a past fact; carry data about what happened
// => Past tense: OrderPlaced, not PlaceOrder (which is a command)
public record OrderPlaced( // => record OrderPlaced
OrderId orderId, // => which order was placed
CustomerId customerId, // => who placed it
Money total, // => total at the moment of placement
Instant occurredAt // => when it happened; Instant = UTC timestamp
) { // => expression
// => record: immutable; structural equality; no setters — events are facts
public static OrderPlaced now(OrderId oid, CustomerId cid, Money total) { // => now method
return new OrderPlaced(oid, cid, total, Instant.now()); // => returns new OrderPlaced(oid, cid, tota
// => factory captures current time; callers don't need to pass timestamp manually
}
}
public record OrderConfirmed( // => record OrderConfirmed
OrderId orderId, // => which order was confirmed
Money total, // => confirmed total (may differ from placed total)
Instant occurredAt // => UTC timestamp
) { // => expression
public static OrderConfirmed now(OrderId oid, Money total) { // => now method
return new OrderConfirmed(oid, total, Instant.now()); // => returns new OrderConfirmed(oid, total,
}
}
// Event is data — create and inspect
OrderPlaced evt = OrderPlaced.now(OrderId.generate(), CustomerId.generate(), new Money(new java.math.BigDecimal("50"), "USD")); // => OrderPlaced.now() called
// => evt.orderId(), evt.customerId(), evt.total(), evt.occurredAt() are all readable
// => evt cannot be mutated — record fields are finalKotlin:
import java.time.Instant // => namespace/package import
// Sealed interface for event hierarchy: all order events share this type
// => sealed: exhaustive when-expressions; compile error if new subtype is missed
sealed interface OrderEvent { // => interface OrderEvent
val orderId: OrderId // => every order event knows its order
val occurredAt: Instant // => every event records when it happened
}
// data class for events: structural equality, toString, copy — all useful for events
data class OrderPlaced( // => class OrderPlaced
override val orderId: OrderId, // => which order
val customerId: CustomerId, // => who placed it
val total: Money, // => total at placement time
override val occurredAt: Instant = Instant.now() // => default to now; override in tests
) : OrderEvent // => expression
data class OrderConfirmed( // => class OrderConfirmed
override val orderId: OrderId, // => expression
val total: Money, // => expression
override val occurredAt: Instant = Instant.now() // => method declaration
) : OrderEvent // => expression
// Usage: when is exhaustive on sealed interface
fun handle(event: OrderEvent) = when (event) { // => handle method
is OrderPlaced -> println("Order placed: ${event.total}") // => typed access to OrderPlaced fields
is OrderConfirmed -> println("Order confirmed: ${event.total}") // => typed access to OrderConfirmed fields
} // => no else needed; sealed ensures exhaustivenessC#:
using System; // => namespace/package import
// Abstract record base: all order domain events share common fields
// => record = immutable; abstract = must subclass; sealed subclasses prevent further extension
public abstract record OrderEvent(OrderId OrderId, DateTimeOffset OccurredAt); // => record OrderEvent
// => OrderId and OccurredAt on every event; no event exists without these
// Concrete event records: immutable; structural ==; past-tense naming
public sealed record OrderPlaced( // => record OrderPlaced
OrderId OrderId, // => inherited from base
CustomerId CustomerId, // => who placed it
Money Total, // => total at placement time
DateTimeOffset OccurredAt // => UTC timestamp
) : OrderEvent(OrderId, OccurredAt); // => expression
// => with-expression available if a projection is needed
public sealed record OrderConfirmed( // => record OrderConfirmed
OrderId OrderId, // => expression
Money Total, // => expression
DateTimeOffset OccurredAt // => expression
) : OrderEvent(OrderId, OccurredAt); // => expression
// Usage: pattern matching is exhaustive with sealed hierarchy
void Handle(OrderEvent e) // => expression
{
_ = e switch { // => _ assigned
OrderPlaced p => Console.WriteLine($"Placed: {p.Total}"), // => typed
OrderConfirmed c => Console.WriteLine($"Confirmed: {c.Total}"), // => typed
_ => throw new ArgumentOutOfRangeException(nameof(e)) // => defensive default
};
}Key Takeaway: Domain Events are immutable records of facts. Past-tense naming (OrderPlaced) signals that the event describes something that happened, not a command to do something.
Why It Matters: Domain Events decouple the moment something happens from the reactions to it. When OrderPlaced is published, a notification service, a loyalty-points service, and an analytics service can each react independently — without the Order aggregate knowing about any of them. This loose coupling is the foundation of scalable event-driven systems.
Example 25: Aggregate publishing domain events
Aggregates collect domain events internally during state changes. The Application Service publishes them after saving, ensuring events are never published for changes that were rolled back.
Java:
import java.time.Instant; // => namespace/package import
import java.util.*; // => namespace/package import
// Aggregate with event collection: events gathered internally; published externally
public class Order { // => Order field
private final OrderId id; // => id field
private final CustomerId customerId; // => customerId field
private OrderStatus status = OrderStatus.PENDING; // => status declared
private final List<Object> _events = new ArrayList<>(); // => internal event queue
public Order(OrderId id, CustomerId customerId) { // => Order method
this.id = id; this.customerId = customerId; // => this.id assigned
// => Collect OrderPlaced when order is created
_events.add(new OrderPlaced(id, customerId, new Money(java.math.BigDecimal.ZERO, "USD"), Instant.now())); // => _events.add() called
}
public void confirm(Money total) { // => confirm method
// => Invariant: only PENDING orders can be confirmed
if (status != OrderStatus.PENDING) throw new IllegalStateException("Order not pending"); // => throws if guard fails
status = OrderStatus.CONFIRMED; // => status assigned
// => Collect event AFTER state change; event reflects new reality
_events.add(new OrderConfirmed(id, total, Instant.now())); // => _events.add() called
}
// Application Service reads events after save, then clears
public List<Object> getDomainEvents() { return Collections.unmodifiableList(_events); } // => getDomainEvents method
public void clearDomainEvents() { _events.clear(); } // => called after publishing
public OrderId getId() { return id; } // => getId method
}
// Application Service: save → publish → clear (order is critical)
public class ConfirmOrderApplicationService { // => ConfirmOrderApplicationService field
private final OrderRepository repo; // => repo field
private final DomainEventPublisher publisher; // => publisher field
public ConfirmOrderApplicationService(OrderRepository repo, DomainEventPublisher pub) { // => ConfirmOrderApplicationService method
this.repo = repo; this.publisher = pub; // => this.repo assigned
}
public void confirm(OrderId id, Money total) { // => confirm method
Order order = repo.findById(id).orElseThrow(); // => load aggregate
order.confirm(total); // => mutate + collect event internally
repo.save(order); // => persist FIRST; transaction commits here
// => Publish AFTER save: if publish fails, at-least-once delivery retries safely
order.getDomainEvents().forEach(publisher::publish); // => order.getDomainEvents() called
order.clearDomainEvents(); // => prevent double-publish on retry
}
}Kotlin:
import java.time.Instant // => namespace/package import
class Order(val id: OrderId, val customerId: CustomerId) { // => class Order
private val _events = mutableListOf<Any>() // => internal event accumulator
val domainEvents: List<Any> get() = _events.toList() // => defensive snapshot
var status: OrderStatus = OrderStatus.PENDING; private set // => expression
init { // => expression
// => Collect placement event when aggregate is constructed
_events += OrderPlaced(id, customerId, Money(java.math.BigDecimal.ZERO, "USD"), Instant.now()) // => _events assigned
// => ends block
}
fun confirm(total: Money) { // => confirm method
check(status == OrderStatus.PENDING) { "Order not pending" } // => precondition check
status = OrderStatus.CONFIRMED // => state change
_events += OrderConfirmed(id, total, Instant.now()) // => event collected after change
// => ends block
}
fun clearDomainEvents() { _events.clear() } // => called by app service after publishing
// => ends block
}
class ConfirmOrderService( // => class ConfirmOrderService
private val repo: OrderRepository, // => expression
private val publisher: DomainEventPublisher // => expression
) { // => expression
fun confirm(id: OrderId, total: Money) { // => confirm method
val order = repo.findById(id) ?: throw IllegalArgumentException("Not found") // => order initialised
order.confirm(total) // => mutate + collect
repo.save(order) // => persist FIRST; publish after
order.domainEvents.forEach { publisher.publish(it) } // => publish collected events
order.clearDomainEvents() // => clear to prevent re-publish
// => ends block
}
}C#:
using System; // => DateTime and InvalidOperationException
using System.Collections.Generic; // => List<T>, IReadOnlyList<T>, KeyNotFoundException
public class Order
{
private readonly List<OrderEvent> _events = new(); // => private accumulator
// => List<OrderEvent>: typed event queue; only OrderEvent subtypes allowed
public IReadOnlyList<OrderEvent> DomainEvents => _events.AsReadOnly();
// => read-only view for Application Service to iterate after save
// => AsReadOnly(): returns wrapper that throws on Add/Remove; original list still mutable inside Order
public OrderId Id { get; }
// => get-only: identity fixed at construction, never reassigned
public CustomerId CustomerId { get; }
// => get-only: customer link immutable after construction
public OrderStatus Status { get; private set; } = OrderStatus.Pending;
// => Starts Pending; private set: only Order methods may change status
public Order(OrderId id, CustomerId customerId)
// => Constructor: only creation path for Order; sets immutable identity fields
{
Id = id; CustomerId = customerId;
// => Assign identity fields; both are get-only after this point
// => Collect event on construction; placement is the first event
_events.Add(new OrderPlaced(id, customerId, new Money(0m, "USD"), DateTimeOffset.UtcNow));
// => OrderPlaced event queued immediately; application service publishes it after save
// => Money(0m, "USD"): placeholder total; real total set when order is confirmed
}
public void Confirm(Money total)
// => Confirm: domain method; validates lifecycle, transitions state, raises event
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException("Order not pending");
// => Lifecycle guard: only Pending orders can be confirmed
// => InvalidOperationException: caller attempted an illegal state transition
Status = OrderStatus.Confirmed; // => state change
// => private set accessible inside Order; external code cannot bypass this guard
_events.Add(new OrderConfirmed(Id, total, DateTimeOffset.UtcNow));
// => event collected AFTER state change; reflects confirmed reality
// => DateTimeOffset.UtcNow captures the exact confirmation timestamp
// => Id is the order identity; total is passed in from application service
}
public void ClearDomainEvents() => _events.Clear(); // => called after publish
// => Clears accumulator; called by app service to prevent double-publish on retry
// => Expression-bodied method: single statement; Clear() removes all elements
}
public class ConfirmOrderService
// => Application Service: thin orchestrator; coordinates domain, repo, and publisher
{
private readonly IOrderRepository _repo;
// => Injected repository; domain code depends on interface, not concrete class
private readonly IDomainEventPublisher _publisher;
// => Injected publisher; domain code does not know about Kafka, RabbitMQ, etc.
public ConfirmOrderService(IOrderRepository repo, IDomainEventPublisher publisher)
// => Constructor injection: dependencies provided by IoC container in production
{
_repo = repo;
// => Stored for use in Confirm method; readonly prevents reassignment
_publisher = publisher;
// => Stored; all event publishing delegated to this abstraction
}
public void Confirm(OrderId id, Money total)
// => Confirm: the application service method; orchestrates load → domain logic → persist → publish
{
var order = _repo.FindById(id) ?? throw new KeyNotFoundException();
// => Load aggregate; null-coalescing throw: KeyNotFoundException if not found
order.Confirm(total); // => mutate + collect
// => Aggregate validates and transitions state; events queued internally
_repo.Save(order); // => persist first
// => DB commit happens here; events must not be published before this succeeds
foreach (var evt in order.DomainEvents) _publisher.Publish(evt); // => publish after save
// => Iterate DomainEvents (read-only); publish each to message bus
order.ClearDomainEvents(); // => prevent double-publish
// => Clear after publish; if method is called again, no events are re-published
}
}Key Takeaway: Aggregates collect events internally; the Application Service publishes them after saving. This ordering guarantees that events are never published for changes that were not persisted.
Why It Matters: Publishing events before saving creates phantom events — listeners react to changes that were then rolled back, causing inconsistency that is hard to detect and painful to compensate. Saving without publishing misses downstream reactions silently. The collect-save-publish sequence is the correct ordering for reliable event-driven systems. Combined with the outbox pattern (see Advanced section), this sequence can be made durable even if the process crashes after saving but before publishing.
Last updated May 8, 2026