Skip to content
AyoKoding

Beginner

Examples 1–25 walk through DDD tactical patterns using the purchasing bounded context of a Procure-to-Pay (P2P) platform. The aggregate is PurchaseRequisition; value objects are Money, SkuCode, Quantity, RequisitionId, ApprovalLevel, and UnitOfMeasure. Every code block is self-contained. Annotation density targets 1.0–2.25 comment lines per code line per example.

Ubiquitous Language and Value Objects (Examples 1–5)

Example 1: Ubiquitous Language — naming domain types from the purchasing glossary

Every class name comes directly from the domain glossary that procurement specialists use. When code says PurchaseRequisition, Money, and Quantity, developers and business analysts share a single vocabulary with no silent translation layer.

// Ubiquitous Language: class names match the purchasing domain glossary exactly
// => "PurchaseRequisition" not "RequestForm"; "Money" not "BigDecimal"; "SkuCode" not "String"
public record PurchaseRequisition(
    RequisitionId id,      // => Strongly-typed id, not raw String or long
    SkuCode       skuCode, // => Domain concept; validates format at construction
    Quantity      quantity, // => Carries unit of measure — int alone does not
    Money         estimatedCost // => Money carries currency — double does not
) {}
 
// Anti-pattern: primitive names lose all purchasing domain meaning
// => String id, String sku — same type, easy to pass in wrong order
// => double amount — what currency? what rounding mode?
public record AntiPattern(String id, String sku, int qty, double amount) {}
 
// Ubiquitous Language version reads like a business requirement
PurchaseRequisition req = new PurchaseRequisition(
    new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000"), // => typed id; wrong kind caught at compile time
    new SkuCode("OFF-001234"),   // => validates regex at construction
    new Quantity(10, UnitOfMeasure.BOX), // => unit embedded in value
    new Money("250.00", "USD")   // => currency embedded; cannot lose it
);

Key Takeaway: Name every domain type using exact vocabulary from the purchasing glossary. When code reads like a procurement business requirement, specification drift surfaces in code review rather than in production.

Why It Matters: Teams using Ubiquitous Language eliminate the silent translation layer between requirements and code. A buyer saying "requisition" and a developer coding PurchaseRequisition speak the same word. Any mismatch becomes immediately visible during review rather than silently causing wrong behavior months later in a live procurement system.


Example 2: Value Object — immutable Money

A Value Object has no identity. Two Money instances with the same amount and currency are equal. Immutability means no operation modifies an existing instance — arithmetic always returns a new Money.

%% Palette: Blue #0173B2, Teal #029E73, Orange #DE8F05
graph LR
    A["Money #40;Value Object#41;"]:::blue
    B["amount: BigDecimal"]:::teal
    C["currency: ISO 4217"]:::teal
    D["add#40;Money#41; → Money"]:::orange
    E["multiply#40;int#41; → Money"]:::orange
    A --> B
    A --> C
    A --> D
    A --> E
 
    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
import java.math.BigDecimal; // => exact decimal arithmetic; never use double for money
import java.util.Objects;    // => null-safe hash helper
 
// Value Object: identity-free; equal fields = interchangeable instances
// => No id field; Money IS its amount + currency
public final class Money { // => final: no subclass can weaken immutability guarantee
    private final BigDecimal amount;   // => final: field cannot be reassigned
    private final String     currency; // => ISO 4217 code, e.g. "USD", "IDR"
 
    public Money(String amount, String currency) { // => String input: avoids double imprecision
        // => Validate before storing — invalid Money must never exist as an object
        if (amount == null)                                    // => null guard
            throw new IllegalArgumentException("amount is null"); // => fails fast; no null Money
        BigDecimal bd = new BigDecimal(amount);               // => NumberFormatException if malformed
        if (bd.compareTo(BigDecimal.ZERO) < 0)                // => domain invariant: amount >= 0
            throw new IllegalArgumentException("amount must be >= 0"); // => negative money rejected
        if (currency == null || currency.length() != 3)       // => ISO 4217 = 3 uppercase letters
            throw new IllegalArgumentException("currency must be 3-letter ISO code"); // => "USDD" or "" rejected
        this.amount   = bd;       // => stored only after validation passes
        this.currency = currency.toUpperCase(); // => normalise; "usd" == "USD"
    }
 
    // Operations return NEW instances; originals are unchanged
    public Money add(Money other) { // => never void; immutable design principle
        if (!this.currency.equals(other.currency))            // => domain rule: cannot add USD + IDR
            throw new IllegalArgumentException("Currency mismatch"); // => cross-currency add is a domain error
        return new Money(this.amount.add(other.amount).toPlainString(), this.currency);
        // => new Money; neither this nor other is mutated
    }
 
    public Money multiply(int factor) { // => scale quantity cost in line items
        if (factor <= 0)                                      // => domain guard: factor must be positive
            throw new IllegalArgumentException("factor must be > 0"); // => multiply by 0 or negative makes no sense
        return new Money(this.amount.multiply(BigDecimal.valueOf(factor)).toPlainString(), this.currency);
        // => new Money returned; this is unchanged
    }
 
    public BigDecimal getAmount()  { return amount; }   // => read-only accessor
    public String    getCurrency() { return currency; } // => read-only accessor
 
    @Override public boolean equals(Object o) { // => structural equality required for VO
        if (!(o instanceof Money m)) return false; // => pattern match; safe cast
        return amount.compareTo(m.amount) == 0 && currency.equals(m.currency);
        // => compareTo not equals: "10.00".equals("10") is false; compareTo is true
    }
    @Override public int hashCode() {
        return Objects.hash(amount.stripTrailingZeros(), currency); // => consistent with compareTo
    }
    @Override public String toString() { return amount + " " + currency; } // => "250.00 USD"
}
 
Money unitPrice = new Money("25.00", "USD");  // => unitPrice = 25.00 USD
Money total     = unitPrice.multiply(10);     // => total     = 250.00 USD (new object)
// unitPrice is still 25.00 USD — immutability guaranteed
Money tax       = new Money("12.50", "USD");  // => tax = 12.50 USD
Money grandTotal = total.add(tax);            // => grandTotal = 262.50 USD

Key Takeaway: Value Objects are immutable and identity-free. All operations return new instances, making shared-state bugs structurally impossible.

Why It Matters: In procurement, prices appear on requisitions, purchase orders, invoices, and payment records simultaneously. If Money were mutable, a price change on one document could silently corrupt another. Immutability eliminates that entire class of concurrency and reference-sharing bugs — the JVM garbage collector handles disposal automatically.


Example 3: Value Object — SkuCode with regex validation

SkuCode wraps a plain string but enforces the procurement catalog format ^[A-Z]{3}-\d{4,8}$. Once constructed, the code is guaranteed valid. Callers never need to re-validate.

import java.util.regex.Pattern; // => compile regex once; reuse for all validations
 
// Value Object with format invariant: guarantees well-formed SKU throughout system
// => Wrapping String in a type also prevents passing arbitrary strings where SkuCode is expected
public final class SkuCode {
    // => compile once at class load; Pattern.compile is expensive
    private static final Pattern FORMAT = Pattern.compile("^[A-Z]{3}-\\d{4,8}$");
    private final String value; // => final: immutable after construction
 
    public SkuCode(String value) {           // => smart constructor enforces invariant
        if (value == null)                   // => null guard first
            throw new IllegalArgumentException("SkuCode cannot be null"); // => null SkuCode is not a SKU
        if (!FORMAT.matcher(value).matches()) // => regex check
            throw new IllegalArgumentException(
                "SkuCode must match [A-Z]{3}-\\d{4,8}, got: " + value); // => e.g. "invalid" or "AB-123" rejected
        this.value = value; // => stored only after validation passes
    }
 
    public String getValue() { return value; } // => read-only; no setter
 
    @Override public boolean equals(Object o) { // => structural equality
        return o instanceof SkuCode s && value.equals(s.value);
    }
    @Override public int    hashCode() { return value.hashCode(); }
    @Override public String toString() { return value; } // => "OFF-001234"
}
 
// Valid usage
SkuCode office = new SkuCode("OFF-001234"); // => office = SkuCode("OFF-001234")
SkuCode tools  = new SkuCode("TLS-9999");  // => tools  = SkuCode("TLS-9999")
System.out.println(office);               // => Output: OFF-001234
 
// Invalid — throws at construction, not later
try {
    SkuCode bad = new SkuCode("invalid");  // => IllegalArgumentException; regex fails
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage());   // => Output: SkuCode must match [A-Z]{3}-\d{4,8}, got: invalid
}
// => bad was never created; invalid state structurally impossible

Key Takeaway: Encode format invariants in the constructor so every SkuCode in the system is guaranteed valid. Downstream code never needs defensive checks.

Why It Matters: In a catalog with thousands of SKUs, a single malformed code silently routes to a non-existent product. Catching that at the construction boundary — not at purchase order issuance or goods receipt — compresses the distance between error and detection from days to milliseconds.


Example 4: Value Object — Quantity with UnitOfMeasure

Quantity pairs a positive integer count with an immutable unit of measure. The enum UnitOfMeasure closes the set of valid units — no magic strings allowed.

// Closed enum: exactly these units exist in the procurement domain
// => Adding "PALLET" requires a deliberate code change, not a stray string
public enum UnitOfMeasure {
    EACH,  // => individual items, e.g. pens
    BOX,   // => packaged boxes
    KG,    // => kilogram weight
    LITRE, // => liquid volume
    HOUR   // => service time, e.g. consulting hours
}
 
// Value Object: count + unit form an inseparable pair
// => Java 21 record: immutable by default; equals/hashCode/toString generated
public record Quantity(int value, UnitOfMeasure unit) {
    // => Compact constructor: runs inside record; no boilerplate field assignments
    public Quantity { // => compact constructor syntax
        if (value <= 0)  // => domain invariant: quantity must be positive
            throw new IllegalArgumentException("Quantity.value must be > 0, got: " + value);
        if (unit == null) // => unit is required; enum ensures valid values
            throw new IllegalArgumentException("UnitOfMeasure required");
        // => fields assigned automatically by record after compact constructor body
    }
}
 
Quantity pens    = new Quantity(500, UnitOfMeasure.EACH);  // => pens    = Quantity[value=500, unit=EACH]
Quantity paper   = new Quantity(10,  UnitOfMeasure.BOX);   // => paper   = Quantity[value=10, unit=BOX]
Quantity consult = new Quantity(8,   UnitOfMeasure.HOUR);  // => consult = Quantity[value=8,  unit=HOUR]
System.out.println(pens);    // => Output: Quantity[value=500, unit=EACH]
System.out.println(paper);   // => Output: Quantity[value=10, unit=BOX]
 
// Invalid — throws immediately
try {
    new Quantity(-1, UnitOfMeasure.KG); // => value <= 0 triggers guard
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage()); // => Output: Quantity.value must be > 0, got: -1
}

Key Takeaway: Quantity as a record pairs count with unit, and the compact constructor enforces the positive-value invariant. The closed enum prevents unit drift from free-form strings.

Why It Matters: A receiving team once misread "500" (EACH) as "500 KG" because both were raw integers in a shared spreadsheet column. Embedding the unit in the type makes that confusion structurally impossible — you cannot create a Quantity without committing to a UnitOfMeasure.


Example 5: Value Object — RequisitionId as a typed identity handle

RequisitionId wraps a UUID string in the format req_<uuid>. Strong typing prevents accidentally using a PurchaseOrderId where a RequisitionId is expected — the compiler catches the mistake.

// Identity value object: not for the aggregate's identity per se, but as a typed reference
// => Record provides equals/hashCode/toString; id comparison is structural
public record RequisitionId(String value) {
    private static final String PREFIX = "req_"; // => format prefix constant
 
    public RequisitionId { // => compact constructor
        if (value == null || value.isBlank()) // => null/blank guard
            throw new IllegalArgumentException("RequisitionId cannot be blank");
        if (!value.startsWith(PREFIX))        // => prefix format check
            throw new IllegalArgumentException("RequisitionId must start with 'req_', got: " + value);
        // => No UUID regex for brevity; production code would add UUID format check
    }
 
    @Override public String toString() { return value; } // => "req_550e8400-..."
}
 
// Separate type for PO ids — compiler blocks accidental swap
public record PurchaseOrderId(String value) {
    public PurchaseOrderId {
        if (value == null || !value.startsWith("po_")) // => prefix check
            throw new IllegalArgumentException("PurchaseOrderId must start with 'po_'");
    }
}
 
RequisitionId rid = new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000");
// => rid = RequisitionId[value=req_550e8400-...]
 
PurchaseOrderId pid = new PurchaseOrderId("po_6ba7b810-9dad-11d1-80b4-00c04fd430c8");
// => pid = PurchaseOrderId[value=po_6ba7b810-...]
 
// The following would be a compile error — type safety enforced
// void approve(RequisitionId id) {}
// approve(pid); // => COMPILE ERROR: PurchaseOrderId ≠ RequisitionId

Key Takeaway: Wrapping each id category in its own record makes id-type mix-ups a compile error instead of a runtime bug traced through logs at 2 AM.

Why It Matters: In a P2P system every workflow step passes multiple ids (requisition, purchase order, supplier, invoice). Primitive id strings are interchangeable in a function call — a compiler cannot help. Typed id records make every incorrect pass visible immediately in the IDE, before the code ever runs.


Smart Constructors and Validation (Examples 6–10)

Example 6: Smart constructor — preventing invalid Money creation

A smart constructor is a factory method or constructor body that rejects invalid inputs before they can reach field assignment. The object is either fully valid or it does not exist.

// Smart constructor: validation inside constructor; no separate validate() step
// => Pattern: check → throw → assign. Never assign then check.
public final class Money {
    private final java.math.BigDecimal amount;
    private final String currency;
 
    public Money(String rawAmount, String rawCurrency) {
        // => Step 1: null guards (fail fast on obviously wrong input)
        if (rawAmount  == null) throw new IllegalArgumentException("amount is null");
        if (rawCurrency == null) throw new IllegalArgumentException("currency is null");
 
        // => Step 2: parse — NumberFormatException surfaces malformed strings
        java.math.BigDecimal bd;
        try {
            bd = new java.math.BigDecimal(rawAmount); // => may throw NumberFormatException
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("amount is not a valid decimal: " + rawAmount, e);
            // => wrap in IllegalArgumentException with context; caller gets clear message
        }
 
        // => Step 3: domain invariants — amount >= 0, currency is 3-letter ISO code
        if (bd.compareTo(java.math.BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("amount must be >= 0, got: " + rawAmount);
        if (rawCurrency.length() != 3 || !rawCurrency.matches("[A-Z]{3}"))
            throw new IllegalArgumentException("currency must be 3-letter uppercase ISO code, got: " + rawCurrency);
 
        // => Step 4: assign ONLY after all checks pass; partial state is impossible
        this.amount   = bd;
        this.currency = rawCurrency;
    }
 
    public java.math.BigDecimal getAmount()  { return amount;   }
    public String               getCurrency(){ return currency; }
    @Override public String toString()       { return amount + " " + currency; }
}
 
// Valid: passes all guards
Money m1 = new Money("250.00", "USD"); // => Money[250.00 USD]
System.out.println(m1);               // => Output: 250.00 USD
 
// Invalid: negative amount
try {
    new Money("-1.00", "USD");         // => guard at Step 3 fires
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage()); // => Output: amount must be >= 0, got: -1.00
}
 
// Invalid: malformed decimal
try {
    new Money("twenty", "USD");        // => NumberFormatException caught at Step 2
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage()); // => Output: amount is not a valid decimal: twenty
}

Key Takeaway: The smart constructor validates in order (null → parse → domain invariant → assign), making invalid object states structurally impossible.

Why It Matters: If validation lives in a separate validate() method, callers can forget to call it. A constructor that validates before assignment makes valid state the only path to existence — the guarantee holds even when callers are written months later by a different team.


Example 7: Compact Constructor / Init Block for Quantity

Compact constructors and their equivalents express invariants concisely without boilerplate field assignment. Java 21 records insert field assignments automatically after the compact constructor body; Kotlin achieves the same guarantee through init blocks; C# through primary constructor validation; and TypeScript through a private constructor with a validating factory method.

// Java 21 record with compact constructor
// => No "this.value = value" needed; compiler inserts it after compact body
public record Quantity(int value, UnitOfMeasure unit) {
    public Quantity { // => compact constructor: no parameter list; fields auto-assigned after
        // => Invariant 1: purchasing domain requires positive quantity
        if (value <= 0)
            throw new IllegalArgumentException("Quantity.value must be > 0, got: " + value);
        // => Invariant 2: unit required; closed enum prevents invalid strings
        if (unit == null)
            throw new IllegalArgumentException("unit is required");
        // => After this block, compiler emits: this.value = value; this.unit = unit;
    }
    // => equals/hashCode/toString generated by record; no manual code needed
}
 
enum UnitOfMeasure { EACH, BOX, KG, LITRE, HOUR } // => closed set; no stray strings
 
// Records have generated accessor methods: value() and unit() (not getters)
Quantity q = new Quantity(10, UnitOfMeasure.BOX);
System.out.println(q.value()); // => Output: 10
System.out.println(q.unit());  // => Output: BOX
System.out.println(q);         // => Output: Quantity[value=10, unit=BOX]
 
// Structural equality: two records with same fields are equal
Quantity q2 = new Quantity(10, UnitOfMeasure.BOX);
System.out.println(q.equals(q2)); // => Output: true (same value+unit)
System.out.println(q == q2);      // => Output: false (different object references; irrelevant for VO)
 
// Invalid
try {
    new Quantity(0, UnitOfMeasure.EACH); // => compact constructor fires; value <= 0
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage()); // => Output: Quantity.value must be > 0, got: 0
}

Key Takeaway: Compact constructors and their equivalents deliver immutability, structural equality, and validation in minimal lines. Java 21 records, Kotlin data class with init, C# record with constructor guards, and TypeScript classes with private constructors and factory methods each achieve these guarantees in their respective idioms.

Why It Matters: Before Java 21 records, a manually written immutable class required final fields, a constructor, two accessors, equals, hashCode, and toString — about 40 lines for a two-field value. Records collapse that to under 10 lines while being provably equivalent. Less boilerplate means fewer opportunities to introduce bugs in the plumbing.


Example 8: ApprovalLevel derived from Money total

ApprovalLevel is a value object derived from the requisition's estimated cost. The derivation rule lives in a factory method rather than in the caller — one source of truth. Each language exposes a factory function on the value type itself: Java a static method, Kotlin a companion-object function, C# a static factory method on the sealed class, and TypeScript a module-level factory function.

// ApprovalLevel enum: derived from PO/requisition total; one derivation rule in one place
// => L1 <= $1000, L2 <= $10000, L3 > $10000
public enum ApprovalLevel {
    L1, // => team lead approval; up to $1,000
    L2, // => department head; $1,001 – $10,000
    L3; // => CFO / board; above $10,000
 
    // Factory method: deriving level from Money keeps rule in the enum, not in callers
    public static ApprovalLevel from(Money total) {
        if (total == null) // => null guard; Money could theoretically be null in early code
            throw new IllegalArgumentException("total is required to derive ApprovalLevel");
        java.math.BigDecimal amount = total.getAmount(); // => extract comparable value
        java.math.BigDecimal oneK   = new java.math.BigDecimal("1000");  // => $1,000 threshold
        java.math.BigDecimal tenK   = new java.math.BigDecimal("10000"); // => $10,000 threshold
 
        if (amount.compareTo(oneK) <= 0)  return L1; // => amount <= 1000 => L1
        if (amount.compareTo(tenK) <= 0)  return L2; // => 1000 < amount <= 10000 => L2
        return L3;                                    // => amount > 10000 => L3
    }
}
 
// Usage: level derived at need; no magic number scattered across services
Money small  = new Money("500.00",   "USD"); // => small  = 500.00 USD
Money medium = new Money("5000.00",  "USD"); // => medium = 5000.00 USD
Money large  = new Money("15000.00", "USD"); // => large  = 15000.00 USD
 
System.out.println(ApprovalLevel.from(small));  // => Output: L1
System.out.println(ApprovalLevel.from(medium)); // => Output: L2
System.out.println(ApprovalLevel.from(large));  // => Output: L3
// => Boundary value: exactly $1000
Money boundary = new Money("1000.00", "USD");
System.out.println(ApprovalLevel.from(boundary)); // => Output: L1 (amount <= 1000)

Key Takeaway: Centralise derivation logic in a factory method on the enum. Callers never need to know threshold values — they call ApprovalLevel.from(total) and get a typed result.

Why It Matters: In a procurement system, approval thresholds change over time. If the rule is embedded in fifteen different service methods, a threshold change requires finding and updating all fifteen. A single factory method contains the rule in one place — one change, zero missed spots.


Example 9: data class / record copy and with — Value Object cloning pitfall

Kotlin's data class generates copy(), Java records and C# records generate with-expression support, and TypeScript classes provide explicit withXxx methods that delegate back to the factory. All four enable creating modified instances without mutation — but each has a nuance around whether invariants re-run on the copy path.

import java.math.BigDecimal;
 
// Java 21 record: compiler generates a copy constructor used by with-expressions in preview
// => Java records do NOT have a built-in with-expression in standard Java 21
// => Idiomatic Java pattern: provide an explicit withCurrency() factory method to retain validation
public record Money(BigDecimal amount, String currency) {
    public Money { // => compact constructor enforces invariants
        if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("amount must be >= 0");
        // => null and negative amount rejected; every Money is born valid
        if (currency == null || currency.length() != 3)
            throw new IllegalArgumentException("currency must be 3-letter ISO code");
        // => currency null and wrong-length rejected
    }
 
    // => Explicit copy factory: re-runs the record constructor, so invariants ARE enforced
    public Money withCurrency(String newCurrency) {
        return new Money(this.amount, newCurrency); // => compact constructor validates newCurrency
    }
 
    public Money add(Money other) { // => immutable: returns new Money; this unchanged
        if (!currency.equals(other.currency)) throw new IllegalArgumentException("Currency mismatch");
        return new Money(amount.add(other.amount), currency);
    }
 
    public Money multiply(int factor) { // => scale line-item cost by quantity
        if (factor <= 0) throw new IllegalArgumentException("factor must be > 0");
        return new Money(amount.multiply(BigDecimal.valueOf(factor)), currency);
    }
}
 
Money unitPrice  = new Money(new BigDecimal("25.00"),  "USD"); // => unitPrice  = Money[25.00, USD]
Money lineTotal  = unitPrice.multiply(10);                     // => lineTotal  = Money[250.00, USD]
Money tax        = new Money(new BigDecimal("12.50"),  "USD"); // => tax        = Money[12.50, USD]
Money grandTotal = lineTotal.add(tax);                         // => grandTotal = Money[262.50, USD]
System.out.println(grandTotal); // => Output: Money[amount=262.50, currency=USD]
 
// Safe copy: withCurrency re-runs the compact constructor, so "IDR" is validated
Money converted = grandTotal.withCurrency("IDR"); // => converted = Money[262.50, IDR]
System.out.println(converted);                    // => Output: Money[amount=262.50, currency=IDR]
 
// Guarded bad copy: compact constructor catches the violation
try {
    grandTotal.withCurrency("XX"); // => currency length != 3; compact constructor rejects
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage()); // => Output: currency must be 3-letter ISO code
}

Key Takeaway: Each language's copy-and-modify mechanism has its own invariant-re-enforcement behaviour. Kotlin data class copy() bypasses init in some versions — verify invariants hold after copy in critical code. C# with-expressions route through the constructor and re-enforce all guards. Java explicit copy methods re-run the compact constructor. TypeScript withXxx methods delegate back to the factory, so guards always run.

Why It Matters: The copy() bypass is a real footgun: Money(BigDecimal("-1"), "USD") cannot be constructed directly, but validMoney.copy(amount = BigDecimal("-1")) can in some Kotlin versions. Understanding this nuance prevents subtle bugs when VO constraints are security-critical (e.g., negative procurement amounts triggering accounting reversals).


Example 10: Record/data-class as Value Object — SkuCode with with/copy semantics

Records, data classes, and their equivalents in all four languages provide structural equality, immutable fields, and copy-construction. Each language has a distinct idiom for creating modified copies without mutation.

import java.util.regex.Pattern;
 
// Java 21 record: compiler generates equals, hashCode, toString, and accessor methods
// => sealed interface would enforce closed hierarchy; record alone gives VO semantics
public record SkuCode(String value) {
    // => Compile regex once at class load — Pattern.compile is expensive per call
    private static final Pattern FORMAT = Pattern.compile("^[A-Z]{3}-\\d{4,8}$");
 
    public SkuCode { // => compact constructor; compiler assigns fields after this body
        if (value == null || value.isBlank())      // => null/blank guard first
            throw new IllegalArgumentException("SkuCode cannot be blank");
        if (!FORMAT.matcher(value).matches())      // => regex guard against full string
            throw new IllegalArgumentException(
                "SkuCode must match [A-Z]{3}-\\d{4,8}, got: " + value);
        // => Fields assigned automatically after compact constructor body
    }
 
    // => Java 21 lacks a built-in with-expression; explicit copy method re-runs the compact constructor
    public SkuCode withValue(String newValue) {
        return new SkuCode(newValue); // => compact constructor validates newValue; invariant re-enforced
    }
 
    @Override public String toString() { return value; } // => "OFF-001234"
}
 
// Valid construction
var office = new SkuCode("OFF-001234"); // => office = SkuCode[value=OFF-001234]
var tools  = new SkuCode("TLS-9999");  // => tools  = SkuCode[value=TLS-9999]
System.out.println(office.equals(tools));                    // => Output: false (different values)
System.out.println(office.equals(new SkuCode("OFF-001234"))); // => Output: true  (structural ==)
 
// Copy with new value — invariant validated at construction
var renamed = office.withValue("TLS-0001"); // => renamed = SkuCode[value=TLS-0001]; office unchanged
System.out.println(renamed);               // => Output: TLS-0001
 
// Invalid — throws at construction; bad is never assigned
try {
    new SkuCode("invalid");              // => regex fails; compact constructor fires
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage()); // => Output: SkuCode must match [A-Z]{3}-\d{4,8}, got: invalid
}

Key Takeaway: Java records use explicit copy methods to re-run compact-constructor guards; Kotlin uses a withValue function that triggers the init block; C# with-expressions route through the regular constructor and re-enforce all validation; TypeScript withValue methods delegate to the static factory, so guards always run.

Why It Matters: In a procurement catalog, an invalid SKU silently routes to a ghost product. Catching malformed codes at construction — not at purchase order creation hours later — compresses the error-detection window from hours to milliseconds and keeps the error message close to the cause.


Entities and the Aggregate Root (Examples 11–15)

Example 11: Entity vs Value Object — identity matters

An Entity has a unique identity that persists across state changes. Two LineItem entities with identical fields but different ids are NOT the same entity.

%% Palette: Blue #0173B2, Teal #029E73, Orange #DE8F05
graph LR
    A["Entity: LineItem"]:::blue
    B["id: LineItemId (identity)"]:::orange
    C["skuCode: SkuCode #40;VO#41;"]:::teal
    D["quantity: Quantity #40;VO#41;"]:::teal
    E["unitPrice: Money #40;VO#41;"]:::teal
    A --> B
    A --> C
    A --> D
    A --> E
 
    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
// LineItemId: typed identity for this entity
public record LineItemId(String value) {
    public LineItemId { // => compact constructor
        if (value == null || value.isBlank())
            throw new IllegalArgumentException("LineItemId cannot be blank");
    }
}
 
// Entity: equality based on id, NOT field values
// => Two line items with identical sku/qty/price but different ids are DIFFERENT entities
public class LineItem {
    private final LineItemId id;      // => identity; never changes after creation
    private final SkuCode    skuCode; // => what is being requisitioned
    private Quantity         quantity; // => mutable: quantity can be revised pre-approval
    private final Money      unitPrice; // => final: price locked at requisition time
 
    public LineItem(LineItemId id, SkuCode skuCode, Quantity quantity, Money unitPrice) {
        // => All required; no partial construction
        if (id == null || skuCode == null || quantity == null || unitPrice == null)
            throw new IllegalArgumentException("All LineItem fields required");
        this.id        = id;
        this.skuCode   = skuCode;
        this.quantity  = quantity;
        this.unitPrice = unitPrice;
    }
 
    // => Domain method: revise quantity pre-approval; business operation, not raw setter
    public void reviseQuantity(Quantity newQty) {
        if (newQty == null) throw new IllegalArgumentException("newQty required");
        this.quantity = newQty; // => allowed before requisition is submitted
    }
 
    public Money lineTotal() {
        return unitPrice.multiply(quantity.value()); // => computed; not stored
    }
 
    // => Entity equality: ONLY id matters; two items with same sku are still different
    @Override public boolean equals(Object o) {
        return o instanceof LineItem li && id.equals(li.id);
    }
    @Override public int hashCode() { return id.hashCode(); }
    @Override public String toString() {
        return "LineItem[" + id + ", " + skuCode + ", " + quantity + ", " + unitPrice + "]";
    }
}
 
// Demonstrate identity-based equality
LineItemId idA = new LineItemId("li-001");
LineItemId idB = new LineItemId("li-002");
SkuCode    sku = new SkuCode("OFF-001234");
Quantity   qty = new Quantity(10, UnitOfMeasure.BOX);
Money      prc = new Money("25.00", "USD");
 
LineItem itemA = new LineItem(idA, sku, qty, prc); // => idA entity
LineItem itemB = new LineItem(idB, sku, qty, prc); // => idB entity; same fields, different id
System.out.println(itemA.equals(itemB)); // => Output: false (different ids)
 
LineItem itemA2 = new LineItem(idA, sku, qty, prc); // => same id as itemA
System.out.println(itemA.equals(itemA2)); // => Output: true (same id)

Key Takeaway: Entities carry identity; two entities are equal only if their ids match, regardless of field values. Value Objects are equal if all fields match, regardless of reference.

Why It Matters: A procurement line item must be traceable from requisition through delivery. If equality were field-based, updating a quantity would make the "updated" item appear to be a completely new item — audit trails would break and receiving teams would have no way to match deliveries back to original lines.


Example 12: PurchaseRequisition as the Aggregate Root

PurchaseRequisition is the Aggregate Root of the purchasing bounded context. All state changes go through its methods — no external code modifies its internals directly.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
 
// PurchaseRequisition: Aggregate Root
// => Controls all state changes; external code calls methods, never fields
public class PurchaseRequisition {
    public enum Status { DRAFT, SUBMITTED, MANAGER_REVIEW, APPROVED, REJECTED, CONVERTED_TO_PO }
 
    private final RequisitionId      id;       // => identity; immutable
    private final String             requesterId; // => who raised the requisition
    private       Status             status;   // => mutable; lifecycle state
    private final List<LineItem>     lineItems; // => encapsulated; exposed read-only
 
    public PurchaseRequisition(RequisitionId id, String requesterId) {
        if (id == null || requesterId == null || requesterId.isBlank())
            throw new IllegalArgumentException("id and requesterId required");
        this.id          = id;
        this.requesterId = requesterId;
        this.status      = Status.DRAFT;    // => starts in DRAFT; only valid initial state
        this.lineItems   = new ArrayList<>(); // => starts empty; lines added via addLine()
    }
 
    // => Domain method: add a line item; only allowed in DRAFT
    public void addLine(LineItem line) {
        if (status != Status.DRAFT)           // => guard: cannot add lines after submission
            throw new IllegalStateException("Lines can only be added in DRAFT, current: " + status);
        if (line == null)
            throw new IllegalArgumentException("line is required");
        lineItems.add(line); // => protected by the aggregate root; no direct list access
    }
 
    // => Domain method: submit for review; guards business rules
    public void submit() {
        if (status != Status.DRAFT)           // => idempotency guard; cannot submit twice
            throw new IllegalStateException("Can only submit from DRAFT, current: " + status);
        if (lineItems.isEmpty())              // => domain rule: no empty requisitions
            throw new IllegalStateException("Cannot submit a requisition with no line items");
        this.status = Status.SUBMITTED;       // => state transition; recorded here
    }
 
    // => Derived value: total computed from all lines; not stored to avoid sync issues
    public Money estimatedTotal() {
        return lineItems.stream()
            .map(LineItem::lineTotal)         // => each line's unit price * quantity
            .reduce(new Money("0.00", "USD"), Money::add); // => fold; default 0.00 USD
    }
 
    // => Derived value: approval level from total
    public ApprovalLevel requiredApprovalLevel() {
        return ApprovalLevel.from(estimatedTotal()); // => delegates to enum factory
    }
 
    // => Read-only view; callers cannot mutate the list
    public List<LineItem> getLineItems() { return Collections.unmodifiableList(lineItems); }
    public RequisitionId  getId()        { return id; }
    public Status         getStatus()    { return status; }
    public String         getRequesterId(){ return requesterId; }
}

Key Takeaway: The Aggregate Root is the sole entry point for all state changes. External code calls its methods; it protects internal consistency.

Why It Matters: Without an Aggregate Root, any code can reach in and modify line items directly, bypassing status checks. In procurement, that means a line could be added to an already-approved requisition without triggering the approval workflow again — a compliance failure that real audit committees flag.


Example 13: Adding line items and computing estimatedTotal

Demonstrating the full lifecycle of a PurchaseRequisition from construction through total computation.

// Setup: value objects
RequisitionId rid  = new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000");
String requesterId = "emp-42"; // => who raised the requisition
 
// Create aggregate in DRAFT state
PurchaseRequisition req = new PurchaseRequisition(rid, requesterId);
// => req.status = DRAFT; req.lineItems = []
 
// Build line items (entities)
LineItem item1 = new LineItem(
    new LineItemId("li-001"),
    new SkuCode("OFF-001234"),       // => office supplies SKU
    new Quantity(500, UnitOfMeasure.EACH),
    new Money("0.50", "USD")         // => $0.50 per pen
); // => li-001: 500 EACH × $0.50 = $250.00
 
LineItem item2 = new LineItem(
    new LineItemId("li-002"),
    new SkuCode("PPR-8500"),         // => paper SKU
    new Quantity(10,  UnitOfMeasure.BOX),
    new Money("25.00", "USD")        // => $25.00 per box
); // => li-002: 10 BOX × $25.00 = $250.00
 
// Add through aggregate root (guards apply)
req.addLine(item1); // => lineItems = [li-001]
req.addLine(item2); // => lineItems = [li-001, li-002]
 
// Derived values
Money total = req.estimatedTotal();
// => total = 250.00 + 250.00 = 500.00 USD
System.out.println(total); // => Output: 500.00 USD
 
ApprovalLevel level = req.requiredApprovalLevel();
// => 500.00 <= 1000.00 => L1
System.out.println(level); // => Output: L1
 
// Submit
req.submit(); // => status transitions DRAFT -> SUBMITTED
System.out.println(req.getStatus()); // => Output: SUBMITTED
 
// Guard: cannot add lines after submission
try {
    req.addLine(item1); // => throws; status is SUBMITTED not DRAFT
} catch (IllegalStateException e) {
    System.out.println(e.getMessage()); // => Output: Lines can only be added in DRAFT, current: SUBMITTED
}

Key Takeaway: The Aggregate Root's domain methods sequence validation, state change, and computation in a single cohesive unit. Callers never manage these steps manually.

Why It Matters: If callers orchestrate validation, state change, and total computation themselves, each caller can get the sequence subtly wrong. Centralising it in submit() means that even a new developer adding a tenth entry point cannot accidentally bypass the "no empty requisition" rule.


Example 14: Immutability in practice — with-style copy via records

Demonstrating the full lifecycle of a PurchaseRequisition from construction through total computation. Java uses a manual withQuantity factory method (records have no built-in with-expression), Kotlin uses data class copy(), C# uses a with-expression, and TypeScript uses an explicit withQuantity method that delegates to the private constructor.

// LineItem as a record: immutable, with a domain-specific copy helper
// => Records in Java 21 cannot have with-expressions; we write a targeted copy method
public record LineItem(LineItemId id, SkuCode skuCode, Quantity quantity, Money unitPrice) {
    public LineItem { // => compact constructor: validate all fields
        if (id == null || skuCode == null || quantity == null || unitPrice == null)
            throw new IllegalArgumentException("All fields required");
    }
 
    // Domain method: create a revised copy with updated quantity
    // => Immutable pattern: never modify; return new instance
    public LineItem withQuantity(Quantity newQuantity) {
        if (newQuantity == null) throw new IllegalArgumentException("newQuantity required");
        return new LineItem(id, skuCode, newQuantity, unitPrice);
        // => id, skuCode, unitPrice unchanged; only quantity is replaced
    }
 
    public Money lineTotal() {
        return unitPrice.multiply(quantity.value()); // => computed from current quantity
    }
}
 
// Demonstrate immutable revision
LineItem original = new LineItem(
    new LineItemId("li-001"),
    new SkuCode("OFF-001234"),
    new Quantity(500, UnitOfMeasure.EACH),
    new Money("0.50", "USD")
); // => original: 500 EACH × $0.50 = $250.00
System.out.println(original.lineTotal()); // => Output: 250.00 USD
 
LineItem revised = original.withQuantity(new Quantity(1000, UnitOfMeasure.EACH));
// => revised: 1000 EACH × $0.50 = $500.00; original is unchanged
System.out.println(revised.lineTotal());  // => Output: 500.00 USD
System.out.println(original.lineTotal()); // => Output: 250.00 USD (original intact)
 
// Identity preserved in revision
System.out.println(original.id().equals(revised.id())); // => Output: true (same id)
// => Same entity identity, different value snapshot — correct for domain revision tracking

Key Takeaway: Returning a new instance from copy-methods (withQuantity) preserves immutability while enabling domain-driven revision semantics. The original is never lost.

Why It Matters: Procurement audits require the history of quantity revisions. If setQuantity mutated in place, the original value would be gone. Immutable copy-returns make it natural to store both versions — before and after — enabling audit log generation without extra infrastructure.


Example 15: Factory method — PurchaseRequisition.create

A static factory method encapsulates construction logic, provides a meaningful name, and can enforce creation-time invariants that go beyond a single constructor call.

// Factory method: named, discoverable, validates creation context
// => hides constructor; callers see intent, not plumbing
public class PurchaseRequisition {
    public enum Status { DRAFT, SUBMITTED, MANAGER_REVIEW, APPROVED, REJECTED, CONVERTED_TO_PO }
 
    private final RequisitionId  id;
    private final String         requesterId;
    private       Status         status;
    private final java.util.List<LineItem> lineItems = new java.util.ArrayList<>();
 
    // Private constructor: callers must use factory
    private PurchaseRequisition(RequisitionId id, String requesterId) {
        this.id          = id;
        this.requesterId = requesterId;
        this.status      = Status.DRAFT;
    }
 
    // Factory method: validates, names intent, constructs
    // => "create" communicates domain action; "new" communicates plumbing
    public static PurchaseRequisition create(RequisitionId id, String requesterId) {
        if (id == null)
            throw new IllegalArgumentException("RequisitionId required");
        if (requesterId == null || requesterId.isBlank())
            throw new IllegalArgumentException("requesterId required; cannot be anonymous");
        // => Future: check requester exists in employee service (application layer concern)
        return new PurchaseRequisition(id, requesterId); // => valid state guaranteed
    }
 
    public void addLine(LineItem line) {
        if (status != Status.DRAFT)
            throw new IllegalStateException("Lines only in DRAFT, current: " + status);
        lineItems.add(line);
    }
    public void submit() {
        if (status != Status.DRAFT)  throw new IllegalStateException("Only from DRAFT");
        if (lineItems.isEmpty())     throw new IllegalStateException("No line items");
        status = Status.SUBMITTED;
    }
    public RequisitionId getId()     { return id; }
    public Status        getStatus() { return status; }
}
 
// Usage: factory method communicates intent
PurchaseRequisition req = PurchaseRequisition.create(
    new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000"),
    "emp-42"
); // => req.status = DRAFT
System.out.println(req.getStatus()); // => Output: DRAFT
 
// Invalid requesterId rejected at factory, not deep inside business logic
try {
    PurchaseRequisition.create(
        new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000"),
        ""   // => blank requesterId; anonymous requisition not allowed
    );
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage()); // => Output: requesterId required; cannot be anonymous
}

Key Takeaway: Static factory methods name the creation intent, control the constructor's visibility, and validate creation-time business rules in a single discoverable location.

Why It Matters: In procurement compliance, a requisition must always be traceable to a named requester. Hiding the constructor behind create ensures that requirement is enforced at the only creation point, not scattered across every caller that might remember (or forget) to check requesterId.


State Machines and Lifecycle (Examples 16–20)

Example 16: State machine — PurchaseRequisition lifecycle

The Status enum with a transition table makes invalid state changes a runtime error rather than a silent no-op.

%% Palette: Blue #0173B2, Teal #029E73, Orange #DE8F05, Purple #CC78BC
stateDiagram-v2
    [*] --> Draft
    Draft --> Submitted : submit
    Submitted --> ManagerReview : escalate
    ManagerReview --> Approved : approve
    ManagerReview --> Rejected : reject
    Approved --> ConvertedToPO : convert
    Rejected --> [*]
    ConvertedToPO --> [*]
import java.util.EnumSet;
import java.util.Map;
 
// Transition table: maps current status to allowed next statuses
// => Explicit table; no ad-hoc if-else scattered across methods
public class PurchaseRequisition {
    public enum Status {
        DRAFT, SUBMITTED, MANAGER_REVIEW, APPROVED, REJECTED, CONVERTED_TO_PO;
 
        private static final Map<Status, EnumSet<Status>> TRANSITIONS = Map.of(
            DRAFT,           EnumSet.of(SUBMITTED),           // => DRAFT -> SUBMITTED only
            SUBMITTED,       EnumSet.of(MANAGER_REVIEW),      // => SUBMITTED -> MANAGER_REVIEW
            MANAGER_REVIEW,  EnumSet.of(APPROVED, REJECTED),  // => manager decides
            APPROVED,        EnumSet.of(CONVERTED_TO_PO),     // => approved -> PO
            REJECTED,        EnumSet.noneOf(Status.class),    // => terminal state
            CONVERTED_TO_PO, EnumSet.noneOf(Status.class)     // => terminal state
        );
 
        public Status transitionTo(Status next) { // => guard then return
            if (!TRANSITIONS.getOrDefault(this, EnumSet.noneOf(Status.class)).contains(next))
                throw new IllegalStateException(
                    "Invalid transition: " + this + " -> " + next);
            return next; // => caller assigns result; this enum value is immutable
        }
    }
 
    private final RequisitionId id;
    private       Status        status = Status.DRAFT;
 
    public PurchaseRequisition(RequisitionId id) {
        if (id == null) throw new IllegalArgumentException("id required");
        this.id = id;
    }
 
    private void transition(Status next) {
        this.status = status.transitionTo(next); // => guard in transitionTo; assigns if valid
    }
 
    public void submit()       { transition(Status.SUBMITTED); }       // => DRAFT -> SUBMITTED
    public void escalate()     { transition(Status.MANAGER_REVIEW); }  // => SUBMITTED -> MANAGER_REVIEW
    public void approve()      { transition(Status.APPROVED); }        // => MANAGER_REVIEW -> APPROVED
    public void reject()       { transition(Status.REJECTED); }        // => MANAGER_REVIEW -> REJECTED
    public void convertToPO()  { transition(Status.CONVERTED_TO_PO); } // => APPROVED -> CONVERTED_TO_PO
 
    public Status getStatus()    { return status; }
    public RequisitionId getId() { return id; }
}
 
// Happy path
PurchaseRequisition req = new PurchaseRequisition(
    new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000"));
req.submit();     // => DRAFT -> SUBMITTED
req.escalate();   // => SUBMITTED -> MANAGER_REVIEW
req.approve();    // => MANAGER_REVIEW -> APPROVED
req.convertToPO(); // => APPROVED -> CONVERTED_TO_PO
System.out.println(req.getStatus()); // => Output: CONVERTED_TO_PO
 
// Invalid transition
PurchaseRequisition req2 = new PurchaseRequisition(
    new RequisitionId("req_550e8400-e29b-41d4-a716-000000000001"));
req2.submit();
try {
    req2.approve(); // => SUBMITTED -> APPROVED is not in table; must go through MANAGER_REVIEW
} catch (IllegalStateException e) {
    System.out.println(e.getMessage()); // => Output: Invalid transition: SUBMITTED -> APPROVED
}

Key Takeaway: A transition table makes every legal state change explicit and enforces it at runtime, preventing silent invalid transitions.

Why It Matters: Approval workflow bypasses are a common source of fraud in procurement systems. An explicit transition table means a requisition cannot skip the manager review step — not by accident, not by a rushed developer, and not by a misconfigured UI. The domain itself enforces the workflow.


Example 17: Guard methods — canSubmit and canApprove

Guard predicates expose read-only queries about state eligibility, useful for UI enablement and pre-submission checks without triggering state changes.

// Adding guard predicates to PurchaseRequisition aggregate
// => canX methods: pure query; no side effects; safe to call any time
public class PurchaseRequisition {
    public enum Status { DRAFT, SUBMITTED, MANAGER_REVIEW, APPROVED, REJECTED, CONVERTED_TO_PO }
 
    private final RequisitionId    id;
    private final java.util.List<LineItem> lineItems = new java.util.ArrayList<>();
    private       Status           status = Status.DRAFT;
 
    public PurchaseRequisition(RequisitionId id) {
        this.id = id;
    }
 
    // => Pure predicate: answers "can this req be submitted now?"
    public boolean canSubmit() {
        return status == Status.DRAFT && !lineItems.isEmpty();
        // => Both conditions required: must be DRAFT and have at least one line
    }
 
    // => Pure predicate: answers "is manager approval currently possible?"
    public boolean canApprove() {
        return status == Status.MANAGER_REVIEW;
        // => Only valid when escalated to manager review
    }
 
    public void addLine(LineItem line) {
        if (status != Status.DRAFT)
            throw new IllegalStateException("Only in DRAFT");
        lineItems.add(line);
    }
 
    public void submit() {
        if (!canSubmit())   // => reuse guard; single source of truth for submit eligibility
            throw new IllegalStateException(
                "Cannot submit: status=" + status + ", lineCount=" + lineItems.size());
        status = Status.SUBMITTED;
    }
 
    public void approve() {
        if (!canApprove())  // => reuse guard
            throw new IllegalStateException("Cannot approve: status=" + status);
        status = Status.APPROVED;
    }
 
    public Status getStatus()    { return status; }
    public int    lineCount()    { return lineItems.size(); }
}
 
PurchaseRequisition req = new PurchaseRequisition(
    new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000"));
 
System.out.println(req.canSubmit()); // => Output: false (no line items)
 
req.addLine(new LineItem(
    new LineItemId("li-001"),
    new SkuCode("OFF-001234"),
    new Quantity(10, UnitOfMeasure.BOX),
    new Money("25.00", "USD")
)); // => lineItems = [li-001]
 
System.out.println(req.canSubmit()); // => Output: true (DRAFT + has lines)
System.out.println(req.canApprove()); // => Output: false (not in MANAGER_REVIEW)
 
req.submit(); // => DRAFT -> SUBMITTED
System.out.println(req.canSubmit()); // => Output: false (no longer DRAFT)

Key Takeaway: Guard predicates (canSubmit, canApprove) are pure queries that expose eligibility without side effects. Domain methods reuse them so eligibility logic has one source of truth.

Why It Matters: Without guard predicates, the eligibility condition is duplicated in the domain method and in every UI button or API pre-check. When the rule changes — say, requiring two line items instead of one — only one method needs updating instead of five.


Example 18: Domain events — recording what happened

A domain event is an immutable record of a significant occurrence in the domain. RequisitionSubmitted is raised when PurchaseRequisition.submit() succeeds.

import java.time.Instant;
 
// Domain Event: immutable record of a fact that occurred in the domain
// => Record: structural equality, toString, hashCode generated; all fields final
public record RequisitionSubmitted(
    RequisitionId requisitionId,  // => which requisition was submitted
    String        requesterId,    // => who submitted it
    Money         estimatedTotal, // => total at time of submission (snapshot)
    ApprovalLevel requiredLevel,  // => approval level needed
    Instant       occurredAt      // => when it happened; Instant = UTC always
) {}
 
// PurchaseRequisition collects events internally
public class PurchaseRequisition {
    public enum Status { DRAFT, SUBMITTED, MANAGER_REVIEW, APPROVED, REJECTED, CONVERTED_TO_PO }
 
    private final RequisitionId id;
    private final String requesterId;
    private       Status  status = Status.DRAFT;
    private final java.util.List<LineItem>             lineItems   = new java.util.ArrayList<>();
    private final java.util.List<RequisitionSubmitted> domainEvents = new java.util.ArrayList<>();
    // => events collected here; application layer dispatches after transaction commit
 
    public PurchaseRequisition(RequisitionId id, String requesterId) {
        this.id = id; this.requesterId = requesterId;
    }
    public void addLine(LineItem l) { if (status==Status.DRAFT) lineItems.add(l); }
 
    public void submit() {
        if (status != Status.DRAFT || lineItems.isEmpty())
            throw new IllegalStateException("Cannot submit");
        status = Status.SUBMITTED; // => state change first
 
        Money total = lineItems.stream()
            .map(LineItem::lineTotal)
            .reduce(new Money("0.00","USD"), Money::add); // => compute total
 
        domainEvents.add(new RequisitionSubmitted( // => record the fact
            id, requesterId, total,
            ApprovalLevel.from(total),
            Instant.now()   // => UTC timestamp; wall clock at commit time
        )); // => event collected; not dispatched here; application layer dispatches
    }
 
    public java.util.List<RequisitionSubmitted> pullEvents() {
        var copy = java.util.List.copyOf(domainEvents); // => immutable snapshot
        domainEvents.clear();                           // => clear after pull; events are one-shot
        return copy;
    }
    public Status getStatus() { return status; }
}
 
// Usage
PurchaseRequisition req = new PurchaseRequisition(
    new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000"), "emp-42");
req.addLine(new LineItem(new LineItemId("li-001"), new SkuCode("OFF-001234"),
    new Quantity(500, UnitOfMeasure.EACH), new Money("0.50","USD")));
req.submit(); // => raises RequisitionSubmitted event internally
 
var events = req.pullEvents(); // => [RequisitionSubmitted{...}]
System.out.println(events.size());                  // => Output: 1
System.out.println(events.get(0).estimatedTotal()); // => Output: 250.00 USD
System.out.println(events.get(0).requiredLevel());  // => Output: L1

Key Takeaway: Domain events capture significant state transitions as immutable facts. The aggregate collects them; the application layer dispatches them after the transaction commits.

Why It Matters: Dispatching events inside the aggregate's own transaction risks double-dispatch on retry. Collecting and pulling after commit is the established pattern for reliable once-exactly event delivery in procurement workflows where duplicate approval emails or duplicate payment triggers are serious compliance issues.


Example 19: Optional — representing absent domain values

Optional<T> (Java), nullable types (Kotlin), and nullable reference types (C#) all make absent domain values explicit. In domain code, findById either returns a PurchaseRequisition or signals "nothing found" — callers cannot ignore the absent case.

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
 
// Simple in-memory repository for illustration
// => Production: this would be an interface; implementation lives in the infrastructure layer
public class InMemoryRequisitionRepository {
    private final Map<String, PurchaseRequisition> store = new HashMap<>();
    // => String key is the RequisitionId.value(); Map is the backing store
 
    public void save(PurchaseRequisition req) {
        store.put(req.getId().value(), req); // => upsert; same id overwrites
    }
 
    // => Returns Optional: caller must explicitly handle the "not found" case
    public Optional<PurchaseRequisition> findById(RequisitionId id) {
        if (id == null) return Optional.empty(); // => null id => empty Optional; no NPE
        return Optional.ofNullable(store.get(id.value()));
        // => ofNullable: empty if not in map; present if found
    }
}
 
// Usage: Optional makes the "not found" branch visible at the call site
InMemoryRequisitionRepository repo = new InMemoryRequisitionRepository();
 
PurchaseRequisition req = new PurchaseRequisition(
    new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000"), "emp-42");
repo.save(req); // => stored
 
// Found case
Optional<PurchaseRequisition> found = repo.findById(
    new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000"));
// => found = Optional[PurchaseRequisition{...}]
found.ifPresent(r -> System.out.println("Found: " + r.getId()));
// => Output: Found: req_550e8400-e29b-41d4-a716-446655440000
 
// Not found case
Optional<PurchaseRequisition> missing = repo.findById(
    new RequisitionId("req_00000000-0000-0000-0000-000000000000"));
// => missing = Optional.empty()
PurchaseRequisition result = missing.orElseThrow(
    () -> new IllegalStateException("Requisition not found"));
// => throws IllegalStateException; caller handles domain "not found" explicitly

Key Takeaway: Optional<T> (Java), nullable types T? (Kotlin), nullable reference types T? (C#), and union types T | null (TypeScript) all make absent values explicit at the API boundary, forcing callers to handle both found and not-found cases rather than receiving a null and failing later.

Why It Matters: In a procurement system, a "not found" requisition could mean it was never created, it was deleted, or the caller has the wrong id. Returning null silently propagates that ambiguity until a NPE surfaces somewhere unexpected. Each language's absence type forces the caller to decide what the absence means — in the right place, at the right time.


Example 20: Line item entity — operator overloads and entity equality

A LineItem entity uses operator overloads (or equivalent named methods) on Money for readable arithmetic and overrides equality to use identity (id) rather than structural field comparison. The pattern is shown in all four languages.

import java.math.BigDecimal;
 
// Stub value objects for this self-contained example
final class Money {
    final BigDecimal amount;
    final String     currency;
    Money(String amount, String currency) {
        this.amount   = new BigDecimal(amount); // => parse once at construction
        this.currency = currency.toUpperCase(); // => normalise to uppercase
    }
    Money multiply(int factor) {                // => scaling: returns new Money
        return new Money(amount.multiply(BigDecimal.valueOf(factor)).toPlainString(), currency);
    }
    @Override public String toString() { return amount.toPlainString() + " " + currency; }
}
 
enum UnitOfMeasure { EACH, BOX, KG, LITRE, HOUR }
 
final class Quantity {
    final int           value;
    final UnitOfMeasure unit;
    Quantity(int value, UnitOfMeasure unit) {
        if (value <= 0) throw new IllegalArgumentException("value > 0 required");
        this.value = value; // => stored after guard
        this.unit  = unit;
    }
}
 
// LineItem as an entity: identity lives in id, not in field values
// => Entity equality uses id only; structural equality would confuse two items for same SKU
public final class LineItem {
    private final String      id;        // => entity identity field
    private final String      skuCode;   // => simplified; production uses SkuCode VO
    private final Quantity    quantity;
    private final Money       unitPrice;
 
    public LineItem(String id, String skuCode, Quantity quantity, Money unitPrice) {
        this.id        = id;        // => identity; never changes
        this.skuCode   = skuCode;
        this.quantity  = quantity;
        this.unitPrice = unitPrice;
    }
 
    public Money lineTotal() {
        return unitPrice.multiply(quantity.value); // => unit price × quantity count
        // => returns new Money; unitPrice unchanged
    }
 
    // => Entity equality: two LineItems are the same entity if they share the same id
    // => Ignores skuCode, quantity, unitPrice — those are attributes, not identity
    @Override public boolean equals(Object o) { return o instanceof LineItem l && id.equals(l.id); }
    @Override public int     hashCode()        { return id.hashCode(); }
    @Override public String  toString()        { return "LineItem(" + id + ", " + skuCode + ")"; }
}
 
// Usage
LineItem item = new LineItem("li-001", "OFF-001234",
    new Quantity(500, UnitOfMeasure.EACH), new Money("0.50", "USD"));
// => item = LineItem(li-001, OFF-001234, qty=500 EACH, unitPrice=0.50 USD)
 
System.out.println(item.lineTotal()); // => Output: 250.00 USD
 
// "Revised" item: same id, different quantity — same entity in the domain
LineItem revised = new LineItem("li-001", "OFF-001234",
    new Quantity(1000, UnitOfMeasure.EACH), new Money("0.50", "USD"));
// => revised shares id "li-001" with item
 
System.out.println(revised.lineTotal()); // => Output: 500.00 USD
System.out.println(item.lineTotal());    // => Output: 250.00 USD (item unchanged; immutable Money)
System.out.println(item.equals(revised)); // => Output: true (same id; entity equality)

Key Takeaway: Entity equality uses identity (id), not structural field comparison. Operator overloads on Money make line-item total computation read as natural arithmetic while preserving immutability.

Why It Matters: Structural equality on a line item would incorrectly treat two separate items for the same SKU as duplicates in a HashSet or dictionary. In procurement, that silently drops one item from the requisition. Overriding equality to use id restores correct entity semantics while keeping the concise data-class or record syntax in Kotlin and C#.


Advanced Value Object Patterns (Examples 21–25)

Example 21: Value Object comparison and ordering — Money

Business rules often require ordering Money values (e.g., checking whether a requisition total exceeds a threshold). Each language provides a standard ordering mechanism: Comparable<Money> in Java, Comparable<Money> in Kotlin, IComparable<Money> in C#, and explicit compareTo/isGreaterThan/isLessThan methods in TypeScript where no ordering interface exists.

import java.math.BigDecimal;
import java.util.Objects;
 
// Money with Comparable: enables direct comparisons and sorting
// => Comparable<Money> allows use in TreeSet, sort(), and compareTo() calls
public final class Money implements Comparable<Money> {
    private final BigDecimal amount;
    private final String     currency;
 
    public Money(String amount, String currency) {
        if (amount == null)  throw new IllegalArgumentException("amount required");
        BigDecimal bd = new BigDecimal(amount);
        if (bd.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("amount >= 0 required");
        if (currency == null || currency.length() != 3)
            throw new IllegalArgumentException("3-letter currency required");
        this.amount   = bd;
        this.currency = currency.toUpperCase(); // => normalise; "usd" → "USD"
    }
 
    // => compareTo: natural ordering by amount within same currency
    @Override public int compareTo(Money other) {
        if (!this.currency.equals(other.currency))         // => guard: only compare same currency
            throw new IllegalArgumentException(
                "Cannot compare Money across currencies: " + currency + " vs " + other.currency);
        return this.amount.compareTo(other.amount);
        // => negative: this < other; 0: equal; positive: this > other
    }
 
    public boolean isGreaterThan(Money other) { return compareTo(other) > 0; }  // => this > other
    public boolean isLessThan(Money other)    { return compareTo(other) < 0; }  // => this < other
 
    public BigDecimal getAmount()  { return amount; }
    public String     getCurrency(){ return currency; }
 
    @Override public boolean equals(Object o) {
        if (!(o instanceof Money m)) return false;
        return amount.compareTo(m.amount) == 0 && currency.equals(m.currency);
        // => compareTo handles "10.00" vs "10"; String equals would not
    }
    @Override public int    hashCode() { return Objects.hash(amount.stripTrailingZeros(), currency); }
    @Override public String toString() { return amount + " " + currency; }
}
 
// Threshold check: does this requisition require L3 approval?
Money total   = new Money("15000.00", "USD"); // => total   = 15000.00 USD
Money l3Floor = new Money("10000.00", "USD"); // => L3 approval threshold
 
System.out.println(total.isGreaterThan(l3Floor)); // => Output: true (15000 > 10000)
 
// Sorting multiple line totals ascending
java.util.List<Money> totals = java.util.List.of(
    new Money("5000.00",  "USD"),
    new Money("250.00",   "USD"),
    new Money("15000.00", "USD")
);
totals.stream()
    .sorted()                         // => uses compareTo; ascending natural order
    .forEach(System.out::println);
// => Output: 250.00 USD
// => Output: 5000.00 USD
// => Output: 15000.00 USD

Key Takeaway: Implementing the platform's ordering mechanism (Comparable in Java/Kotlin, IComparable<T> in C#, or explicit comparison methods in TypeScript) on a Value Object enables direct ordering, making threshold checks and sorting idiomatic and safe.

Why It Matters: Procurement systems compare request totals against approval thresholds constantly. Without the ordering interface, callers extract the raw numeric amount and compare it directly — losing the currency-mismatch guard. With compareTo/CompareTo on Money, a cross-currency comparison fails loudly instead of silently producing a meaningless ordering.


Example 22: Defensive copying — protecting mutable collections in the aggregate

The aggregate must not expose mutable internal collections. Returning an unmodifiable view or a defensive copy prevents external code from bypassing domain logic.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
 
// Aggregate: demonstrates defensive copy patterns for internal collections
public class PurchaseRequisition {
    public enum Status { DRAFT, SUBMITTED, APPROVED }
    private final RequisitionId id;
    private       Status        status = Status.DRAFT;
    private final List<LineItem> lineItems = new ArrayList<>(); // => mutable internally
 
    public PurchaseRequisition(RequisitionId id) { this.id = id; }
 
    public void addLine(LineItem line) {
        if (status != Status.DRAFT) throw new IllegalStateException("Only in DRAFT");
        lineItems.add(line);
    }
 
    // Pattern A: unmodifiable view (cheapest; backed by original list)
    // => Changes to internal list visible through view, but caller cannot add/remove
    public List<LineItem> getLineItems() {
        return Collections.unmodifiableList(lineItems); // => wrapper; O(1)
        // => UnsupportedOperationException if caller calls add/remove
    }
 
    // Pattern B: defensive copy (most isolated; caller gets a snapshot)
    // => Caller changes don't affect aggregate; aggregate changes don't surprise caller
    public List<LineItem> getLineItemsCopy() {
        return List.copyOf(lineItems); // => Java 9+; immutable copy; O(n)
        // => caller has a frozen snapshot; fine for read-only consumers
    }
 
    public Status getStatus() { return status; }
}
 
PurchaseRequisition req = new PurchaseRequisition(
    new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000"));
req.addLine(new LineItem(new LineItemId("li-001"), new SkuCode("OFF-001234"),
    new Quantity(10, UnitOfMeasure.BOX), new Money("25.00","USD")));
 
// Pattern A: unmodifiable view
List<LineItem> view = req.getLineItems();
System.out.println(view.size()); // => Output: 1
 
try {
    view.add(null); // => UnsupportedOperationException; cannot mutate through view
} catch (UnsupportedOperationException e) {
    System.out.println("Add rejected by unmodifiable view"); // => Output: Add rejected by unmodifiable view
}
 
// Pattern B: copy
List<LineItem> copy = req.getLineItemsCopy();
System.out.println(copy.size()); // => Output: 1 (snapshot)
// Adding another line to aggregate does not affect the copy already taken
req.addLine(new LineItem(new LineItemId("li-002"), new SkuCode("PPR-8500"),
    new Quantity(5, UnitOfMeasure.BOX), new Money("50.00","USD")));
System.out.println(copy.size()); // => Output: 1 (snapshot frozen at copy time)
System.out.println(req.getLineItems().size()); // => Output: 2 (live view)

Key Takeaway: Expose internal collections only through unmodifiable views or defensive copies. This is the boundary that keeps the Aggregate Root the sole controller of its state.

Why It Matters: A mutable list reference returned directly allows any caller to add, remove, or reorder line items without triggering domain guards. In procurement, that means bypassing quantity checks, status guards, and approval recalculation — all silently. Defensive exposure makes the encapsulation boundary real, not just a naming convention.


Example 23: Immutable copy-and-modify — Quantity revision across languages

Each language provides a mechanism for creating a modified copy of an immutable value: Java uses a manual withValue factory method on records, Kotlin uses copy() on data classes, C# uses with-expressions on records, and TypeScript uses explicit withXxx methods that delegate back to the factory. All four preserve the original and produce a distinct revised instance.

// Java 21 record: immutable by construction; manual withValue factory for copy-and-modify
// => Records do not support field setters; to revise, produce a new instance
public record Quantity(int value, UnitOfMeasure unit) {
    Quantity {
        if (value <= 0)  throw new IllegalArgumentException("value > 0 required"); // => domain guard
        if (unit == null) throw new IllegalArgumentException("unit required");       // => null guard
    }
 
    // withValue: idiomatic Java record "copy-and-modify" factory; preserves unit
    // => Naming convention: "with<Field>" mirrors Kotlin copy() and C# with-expression intent
    public Quantity withValue(int newValue) {
        return new Quantity(newValue, this.unit); // => new instance; original untouched
    }
}
 
enum UnitOfMeasure { EACH, BOX, KG, LITRE, HOUR }
 
// LineItem record: holds a Quantity; demonstrates copy-and-modify through the aggregate
public record LineItem(String id, String skuCode, Quantity quantity, double unitPrice) {
    public double lineTotal() { return unitPrice * quantity.value(); } // => derived; no backing field
}
 
// Demonstrate manual copy-and-modify on LineItem
LineItem original = new LineItem("li-001", "OFF-001234",
    new Quantity(500, UnitOfMeasure.EACH), 0.50);
// => original: 500 EACH × $0.50 = $250.00
System.out.println(original.lineTotal()); // => Output: 250.0
 
// Revise quantity by producing a new Quantity and reconstructing the LineItem record
Quantity revisedQty = original.quantity().withValue(1000);
// => revisedQty: 1000 EACH; original.quantity() still 500 EACH
LineItem revised = new LineItem(original.id(), original.skuCode(), revisedQty, original.unitPrice());
// => revised: 1000 EACH × $0.50 = $500.00
System.out.println(revised.lineTotal());  // => Output: 500.0
System.out.println(original.lineTotal()); // => Output: 250.0 (original intact)
 
// Identity and equality
System.out.println(original.id().equals(revised.id())); // => Output: true (same entity id)
System.out.println(original.equals(revised));           // => Output: false (Quantity differs; structural ==)

Key Takeaway: All four languages provide a copy-and-modify mechanism for immutable types — Java's with<Field> factory, Kotlin's copy(), C#'s with-expression, and TypeScript's explicit withXxx factory delegation — each producing a new instance while leaving the original unchanged.

Why It Matters: The copy-and-modify pattern is not just syntactic sugar — it communicates "this is an immutable copy with one field changed," which is exactly the intent when revising a procurement line item before approval. That intent is lost when callers call a setter or construct from scratch with scattered arguments.


Example 24: Aggregate boundary — rejecting external line item mutation

This example tests the aggregate's encapsulation boundary directly: external code should not be able to mutate line items by keeping a reference from before they were added.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
 
// Demonstrates that aggregate encapsulation holds even when caller holds a reference
// => Key test: does mutating the original object mutate the aggregate's internal state?
 
// For this test, LineItem is a mutable entity (pre-Java-record style)
class MutableLineItem {
    private final String id;
    private       int    quantity; // => mutable: can be changed
 
    MutableLineItem(String id, int quantity) {
        this.id = id;
        this.quantity = quantity;
    }
 
    void setQuantity(int q) { this.quantity = q; } // => setter: mutation point
    int  getQuantity()      { return quantity; }
    String getId()          { return id; }
}
 
class ReqWithMutableLines {
    private final List<MutableLineItem> lines = new ArrayList<>();
 
    // Defensive store: does NOT copy; stores reference directly (anti-pattern shown)
    void addLineDirect(MutableLineItem line) { lines.add(line); } // => anti-pattern
 
    // Defensive store: copies on input (correct pattern)
    void addLineSafe(MutableLineItem line) {
        lines.add(new MutableLineItem(line.getId(), line.getQuantity())); // => defensive copy
        // => now internal copy is independent of caller's reference
    }
 
    List<MutableLineItem> getLines() { return Collections.unmodifiableList(lines); }
}
 
MutableLineItem external = new MutableLineItem("li-001", 10);
// => external.quantity = 10
 
ReqWithMutableLines badReq = new ReqWithMutableLines();
badReq.addLineDirect(external); // => stores reference; no copy
System.out.println(badReq.getLines().get(0).getQuantity()); // => Output: 10
 
external.setQuantity(999); // => caller mutates original
System.out.println(badReq.getLines().get(0).getQuantity()); // => Output: 999 (leaked mutation!)
// => aggregate's internal state was silently changed; encapsulation broken
 
ReqWithMutableLines goodReq = new ReqWithMutableLines();
goodReq.addLineSafe(external); // => stores defensive copy
System.out.println(goodReq.getLines().get(0).getQuantity()); // => Output: 999 (copy took current value)
 
external.setQuantity(12345); // => caller mutates again
System.out.println(goodReq.getLines().get(0).getQuantity()); // => Output: 999 (copy unaffected)
// => aggregate's copy is independent; encapsulation holds

Key Takeaway: Storing references to mutable objects breaks aggregate encapsulation — external mutation leaks through. Defensive copy on input prevents the leak.

Why It Matters: Records and immutable Value Objects eliminate this class of problem entirely. If mutable entities must be used, defensive copying on input is the safety net that prevents external code from bypassing aggregate guards. In procurement, a bypassed guard could mean a line item quantity changes after manager approval — a compliance gap detectable only by a full audit.


Example 25: Putting it together — full PurchaseRequisition lifecycle

A complete end-to-end demonstration: create a requisition, add lines with value objects, submit, verify approval level, and check the domain event raised.

import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
 
// ---- Value Objects (abbreviated; full validation shown in earlier examples) ----
record RequisitionId(String value) {
    RequisitionId { Objects.requireNonNull(value, "id required"); }
}
record SkuCode(String value) {
    SkuCode { if (value == null || !value.matches("^[A-Z]{3}-\\d{4,8}$"))
                  throw new IllegalArgumentException("invalid SkuCode: " + value); }
}
record Quantity(int value, UnitOfMeasure unit) {
    Quantity { if (value <= 0) throw new IllegalArgumentException("qty > 0 required"); }
}
enum UnitOfMeasure { EACH, BOX, KG, LITRE, HOUR }
 
final class Money {
    final BigDecimal amount; final String currency;
    Money(String a, String c) {
        amount = new BigDecimal(a);
        if (amount.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("amount >= 0");
        if (c == null || c.length() != 3)           throw new IllegalArgumentException("3-letter currency");
        currency = c.toUpperCase();
    }
    Money add(Money o) {
        if (!currency.equals(o.currency)) throw new IllegalArgumentException("currency mismatch");
        return new Money(amount.add(o.amount).toPlainString(), currency);
    }
    Money multiply(int f) {
        if (f <= 0) throw new IllegalArgumentException("factor > 0");
        return new Money(amount.multiply(BigDecimal.valueOf(f)).toPlainString(), currency);
    }
    @Override public String toString() { return amount + " " + currency; }
}
 
enum ApprovalLevel {
    L1, L2, L3;
    static ApprovalLevel from(Money m) {
        BigDecimal a = m.amount;
        if (a.compareTo(new BigDecimal("1000")) <= 0) return L1;
        if (a.compareTo(new BigDecimal("10000")) <= 0) return L2;
        return L3;
    }
}
 
// ---- Entity ----
record LineItem(String id, SkuCode skuCode, Quantity quantity, Money unitPrice) {
    Money lineTotal() { return unitPrice.multiply(quantity.value()); }
}
 
// ---- Domain Event ----
record RequisitionSubmitted(RequisitionId id, Money total, ApprovalLevel level, Instant at) {}
 
// ---- Aggregate Root ----
class PurchaseRequisition {
    enum Status { DRAFT, SUBMITTED }
    private final RequisitionId id;
    private final String        requesterId;
    private       Status        status     = Status.DRAFT;
    private final List<LineItem>             lines  = new ArrayList<>();
    private final List<RequisitionSubmitted> events = new ArrayList<>();
 
    static PurchaseRequisition create(RequisitionId id, String requesterId) {
        if (id == null || requesterId == null || requesterId.isBlank())
            throw new IllegalArgumentException("id and requesterId required");
        return new PurchaseRequisition(id, requesterId);
    }
    private PurchaseRequisition(RequisitionId id, String requesterId) {
        this.id = id; this.requesterId = requesterId;
    }
 
    void addLine(LineItem l) {
        if (status != Status.DRAFT) throw new IllegalStateException("only in DRAFT");
        lines.add(l); // => aggregate controls all line additions
    }
 
    void submit() {
        if (status != Status.DRAFT || lines.isEmpty())
            throw new IllegalStateException("cannot submit");
        status = Status.SUBMITTED;                         // => state transition
        Money total = lines.stream()
            .map(LineItem::lineTotal)
            .reduce(new Money("0.00","USD"), Money::add);  // => compute total
        events.add(new RequisitionSubmitted(id, total, ApprovalLevel.from(total), Instant.now()));
        // => domain event collected; application layer will dispatch
    }
 
    List<RequisitionSubmitted> pullEvents() {
        var e = List.copyOf(events); events.clear(); return e; // => pull and clear
    }
    Status getStatus()       { return status; }
    RequisitionId getId()    { return id; }
    Money estimatedTotal()   {
        return lines.stream().map(LineItem::lineTotal).reduce(new Money("0.00","USD"), Money::add);
    }
}
 
// ---- Full lifecycle demonstration ----
PurchaseRequisition req = PurchaseRequisition.create(
    new RequisitionId("req_550e8400-e29b-41d4-a716-446655440000"),
    "emp-42"
); // => status=DRAFT; lines=[]
 
req.addLine(new LineItem("li-001", new SkuCode("OFF-001234"),
    new Quantity(500, UnitOfMeasure.EACH), new Money("0.50","USD")));
// => li-001: 500 EACH × $0.50 = $250.00
 
req.addLine(new LineItem("li-002", new SkuCode("PPR-8500"),
    new Quantity(10, UnitOfMeasure.BOX), new Money("25.00","USD")));
// => li-002: 10 BOX × $25.00 = $250.00
 
System.out.println("Total: " + req.estimatedTotal()); // => Output: Total: 500.00 USD
System.out.println("Level: " + ApprovalLevel.from(req.estimatedTotal())); // => Output: Level: L1
 
req.submit(); // => DRAFT -> SUBMITTED; raises RequisitionSubmitted event
System.out.println("Status: " + req.getStatus()); // => Output: Status: SUBMITTED
 
var events = req.pullEvents(); // => [RequisitionSubmitted{...}]
System.out.println("Events: " + events.size());              // => Output: Events: 1
System.out.println("Event total: " + events.get(0).total()); // => Output: Event total: 500.00 USD
System.out.println("Event level: " + events.get(0).level()); // => Output: Event level: L1

Key Takeaway: The full lifecycle — value object construction, entity creation, aggregate-root-gated operations, derived values, and domain event collection — flows through a single coherent model without any framework annotations in the domain layer.

Why It Matters: Pure domain models are independently testable, portable across frameworks, and readable by non-engineers familiar with the procurement domain. When the entire requisition lifecycle can be exercised with no framework context or database, the domain is correctly isolated — and onboarding a new developer takes minutes, not days.

Last updated May 8, 2026

Command Palette

Search for a command to run...