Skip to content
AyoKoding

Beginner

This tutorial teaches DDD tactical patterns — ubiquitous language, value objects, entities, aggregate roots, and domain events — through the procurement-platform-be domain. Go is the canonical language, following Matthew Boyle's Domain-Driven Design with Golang (Packt, 2022). Rust shows how ownership reshapes aggregate modelling: &mut self methods enable in-place mutation, while consuming self transitions enforce that the old state is unreachable after a state change.

Canonical sources: Matthew Boyle — Domain-Driven Design with Golang (Packt, 2022); Three Dots Labs — DDD + CQRS + Clean Architecture in Go; Jim Blandy, Jason Orendorff, Leonora F. S. Tindall — Programming Rust, 3rd ed. (O'Reilly, 2024).

Ubiquitous Language (Examples 1–5)

Example 1: Money as a Domain Primitive

Using a raw int64 for monetary amounts causes currency-mismatch bugs that only surface at runtime. Wrapping the primitive in a Money struct makes invalid operations — like adding USD to THB — a caught error rather than a silent data corruption. In the procurement domain, every purchase order line item, invoice, and payment approval references Money, so correctness here propagates everywhere.

classDiagram
  class Money {
    +amountCents int64
    +Currency string
    +New(cents int64, currency string) Money
    +Add(other Money) Money
    +String() string
  }
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class Money:::blue
// => Money wraps the primitive to prevent misuse at compile and runtime.
// => Exporting only the type, not the fields, forces callers through New().
type Money struct {
  // => amountCents stores value in smallest currency unit — avoids float rounding.
  // => int64 handles amounts up to ~92 quadrillion cents without overflow.
  amountCents int64
  // => Currency holds ISO 4217 three-letter code ("USD", "THB").
  Currency string
}
 
// => NewMoney is the sole construction path — validation happens once here.
// => Returning (Money, error) follows Go's explicit error idiom.
func NewMoney(cents int64, currency string) (Money, error) {
  // => Three-character check enforces ISO 4217 format at the boundary.
  // => Invalid currency never enters the domain after this point.
  if len(currency) != 3 {
    return Money{}, fmt.Errorf("currency must be 3 chars, got %q", currency)
  }
  // => Negative amounts are rejected — procurement deals in positive values.
  if cents < 0 {
    return Money{}, fmt.Errorf("amount must be non-negative, got %d", cents)
  }
  return Money{amountCents: cents, Currency: currency}, nil
}
 
// => Add returns a NEW Money — value receiver enforces immutability convention.
// => Error if currencies differ: adding USD to THB is always a domain error.
func (m Money) Add(other Money) (Money, error) {
  // => Currency check at add time prevents silent cross-currency totals.
  if m.Currency != other.Currency {
    return Money{}, fmt.Errorf("currency mismatch: %s + %s", m.Currency, other.Currency)
  }
  // => amountCents addition is exact — no floating-point drift.
  return Money{amountCents: m.amountCents + other.amountCents, Currency: m.Currency}, nil
}
 
// => String() formats for human display: "USD 10.50" (cents ÷ 100).
func (m Money) String() string {
  // => Divide by 100 for display only — internal representation stays in cents.
  return fmt.Sprintf("%s %.2f", m.Currency, float64(m.amountCents)/100)
}

Key takeaway: Wrap monetary primitives in a domain type with a validating constructor. Currency mismatches become caught errors, not silent data corruption.


Example 2: PurchaseOrderId Newtype

Passing the wrong ID type to a function is a category of bug that raw strings enable and newtypes prevent. A function expecting PurchaseOrderId will reject a SupplierId at compile time if both are distinct named types, even though both wrap a UUID string underneath. In the procurement domain, IDs cross service and repository boundaries constantly — type safety here eliminates a whole class of routing errors.

classDiagram
  class PurchaseOrderId {
    +value string
    +New() PurchaseOrderId
    +String() string
  }
  class SupplierId {
    +value string
    +New() SupplierId
    +String() string
  }
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class PurchaseOrderId:::blue
  class SupplierId:::teal
// => Named type over string — distinct type, not just an alias.
// => Go type aliases (type X = string) share the same type; named types do not.
type PurchaseOrderId string
 
// => NewPurchaseOrderId generates a new RFC-4122 v4 UUID.
// => Factory function is the only construction point — no raw string casts outside domain.
func NewPurchaseOrderId() PurchaseOrderId {
  // => uuid.New() from github.com/google/uuid — cryptographically random v4.
  // => .String() returns lowercase hyphenated form: "550e8400-e29b-41d4-a716-446655440000".
  return PurchaseOrderId(uuid.New().String())
}
 
// => String() satisfies fmt.Stringer — safe extraction for serialisation.
// => Callers must call .String() explicitly — no implicit unwrapping.
func (id PurchaseOrderId) String() string {
  return string(id)
}
 
// => SupplierId is a parallel newtype — same implementation, incompatible type.
// => func f(id PurchaseOrderId) won't accept SupplierId — compile error.
type SupplierId string
 
func NewSupplierId() SupplierId {
  // => Same uuid source — different type prevents ID swapping between aggregates.
  return SupplierId(uuid.New().String())
}
 
func (id SupplierId) String() string {
  return string(id)
}

Key takeaway: Newtype wrappers around ID strings give the compiler enough information to reject ID swaps between aggregates — a zero-runtime-cost correctness guarantee.


Example 3: POStatus Enumeration

All legal states of a purchase order should be explicit and exhaustive — no magic strings like "approved" that silently pass a typo through. Enumerating states as named constants (Go) or enum variants (Rust) forces every state-handling code path to be deliberate. In the procurement lifecycle, a PO moves from Draft through Submitted, ApprovalPending, Issued, Received, Paid, and optionally Cancelled or Disputed — each transition has preconditions enforced later by the aggregate root.

stateDiagram-v2
  [*] --> Draft
  Draft --> Submitted : submit
  Submitted --> ApprovalPending : route for approval
  ApprovalPending --> Issued : approve
  ApprovalPending --> Cancelled : reject
  Issued --> Received : goods received
  Received --> Paid : invoice paid
  Issued --> Disputed : raise dispute
  Disputed --> Cancelled : resolve cancelled
  Disputed --> Issued : resolve reissued
// => Underlying int type gives compact in-memory representation.
// => Unexported iota values are zero-indexed — Draft == 0.
type POStatus int
 
const (
  // => iota auto-increments — Draft=0, Submitted=1, etc.
  // => Explicit first value documents that zero-value is Draft (safe default).
  Draft          POStatus = iota // 0
  Submitted                      // 1
  ApprovalPending                // 2
  Issued                         // 3
  Received                       // 4
  Paid                           // 5
  Cancelled                      // 6
  Disputed                       // 7
)
 
// => String() prevents "0" appearing in logs — human-readable status names.
// => Go lacks exhaustive match; a linter (exhaustive) should check switch completeness.
func (s POStatus) String() string {
  switch s {
  case Draft:
    return "Draft"
  case Submitted:
    return "Submitted"
  case ApprovalPending:
    return "ApprovalPending"
  case Issued:
    return "Issued"
  case Received:
    return "Received"
  case Paid:
    return "Paid"
  case Cancelled:
    return "Cancelled"
  case Disputed:
    return "Disputed"
  default:
    // => Default branch catches unknown values that could appear after deserialization.
    return fmt.Sprintf("POStatus(%d)", int(s))
  }
}

Key takeaway: Enumerate domain states explicitly. Rust enforces exhaustive handling at compile time; Go requires a linter. Either way, magic strings are eliminated.


Example 4: ApprovalLevel Value Object

Business rules that determine who must approve a purchase order — how many approvers, up to what budget — belong inside a value object constructor, not scattered across approval workflow code. An ApprovalLevel with an invalid tier or a zero budget cap should be impossible to construct. In the procurement domain, approval levels feed directly into the PO aggregate root's Approve method, which checks the budget cap before issuing the PO.

classDiagram
  class ApprovalLevel {
    +tier int
    +requiredApprovers int
    +budgetCapCents int64
    +New(tier int, required int, capCents int64) ApprovalLevel
    +Tier() int
    +RequiredApprovers() int
    +BudgetCapCents() int64
  }
 
  classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class ApprovalLevel:::orange
// => All fields unexported — callers cannot bypass validation by direct field assignment.
// => Accessors expose read-only view without allowing mutation.
type ApprovalLevel struct {
  tier              int
  requiredApprovers int
  // => budgetCapCents mirrors Money.amountCents — same unit, same precision.
  budgetCapCents    int64
}
 
// => NewApprovalLevel is the only valid construction path.
// => Validation at construction = impossible to have an invalid level inside the domain.
func NewApprovalLevel(tier, required int, capCents int64) (ApprovalLevel, error) {
  // => Tier 1-3 maps to department → VP → C-suite escalation path.
  if tier < 1 || tier > 3 {
    return ApprovalLevel{}, fmt.Errorf("tier must be 1-3, got %d", tier)
  }
  // => At least one approver required — zero would allow self-approval bypass.
  if required < 1 {
    return ApprovalLevel{}, fmt.Errorf("requiredApprovers must be >= 1, got %d", required)
  }
  // => Zero or negative cap is nonsensical for a budget ceiling.
  if capCents <= 0 {
    return ApprovalLevel{}, fmt.Errorf("budgetCapCents must be > 0, got %d", capCents)
  }
  return ApprovalLevel{tier: tier, requiredApprovers: required, budgetCapCents: capCents}, nil
}
 
// => Accessors provide read-only access — value object cannot mutate after construction.
func (a ApprovalLevel) Tier() int              { return a.tier }
func (a ApprovalLevel) RequiredApprovers() int { return a.requiredApprovers }
func (a ApprovalLevel) BudgetCapCents() int64  { return a.budgetCapCents }

Key takeaway: Encode business rules inside value object constructors. An invalid ApprovalLevel becomes unrepresentable — the invariant is guaranteed by the type system, not by scattered runtime checks.


Example 5: SupplierCode Validated Identifier

Domain identifiers often carry format rules — a SupplierCode in the procurement system uses a two-letter country prefix followed by a six-digit sequence (TH-001234). Parsing at the domain boundary ensures malformed codes never flow inward past the constructor. Once parsed, no re-validation is needed — the type itself is the proof of validity.

classDiagram
  class SupplierCode {
    +value string
    +Parse(s string) SupplierCode
    +String() string
  }
 
  classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class SupplierCode:::teal
// => Named string type — distinct from SupplierId and other string-based types.
type SupplierCode string
 
// => supplierCodePattern is compiled once at init time — not per call.
// => ^[A-Z]{2}-\d{6}$ means: two uppercase letters, hyphen, six digits, end.
var supplierCodePattern = regexp.MustCompile(`^[A-Z]{2}-\d{6}$`)
 
// => ParseSupplierCode validates at the boundary — rejects bad input here.
// => Returns (SupplierCode, error) following Go's explicit error convention.
func ParseSupplierCode(s string) (SupplierCode, error) {
  // => MatchString runs the pre-compiled regex — cheap after first call.
  if !supplierCodePattern.MatchString(s) {
    return "", fmt.Errorf("invalid supplier code format: %q (expected XX-######)", s)
  }
  // => Conversion is safe after validation — no further checks needed downstream.
  return SupplierCode(s), nil
}
 
// => String() enables fmt.Sprintf and logging without explicit casting.
func (c SupplierCode) String() string {
  return string(c)
}

Key takeaway: Parse domain identifiers at the boundary, not on every use. A successfully constructed SupplierCode is proof of validity — no downstream re-validation required.


Value Objects (Examples 6–12)

Example 6: Quantity with Unit of Measure

A number without a unit is a ticking ambiguity. Adding 10 kilograms to 10 pieces produces a nonsensical result that only manifests in a warehouse discrepancy or an incorrect invoice. Wrapping quantity and unit together, and rejecting mixed-unit arithmetic, encodes this domain rule at the type level.

classDiagram
  class Quantity {
    +Amount float64
    +Unit Unit
    +New(amount float64, unit Unit) Quantity
    +Add(other Quantity) Quantity
  }
  class Unit {
    <<enumeration>>
    Each
    Kg
    Litre
  }
  Quantity --> Unit
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class Quantity:::blue
  class Unit:::orange
// => Unit is a named string type — readable in logs and serialization.
// => String-based enum avoids magic numbers while keeping interoperability.
type Unit string
 
const (
  // => UnitEach for countable discrete items (pens, chairs, servers).
  UnitEach  Unit = "each"
  // => UnitKg for bulk materials sold by weight.
  UnitKg    Unit = "kg"
  // => UnitLitre for liquids sold by volume.
  UnitLitre Unit = "litre"
)
 
// => Quantity bundles amount and unit — inseparable in the domain.
type Quantity struct {
  // => float64 acceptable for quantity — unlike money, small rounding is tolerable.
  Amount float64
  Unit   Unit
}
 
// => NewQuantity validates amount > 0 — negative or zero quantities are nonsensical.
func NewQuantity(amount float64, unit Unit) (Quantity, error) {
  if amount <= 0 {
    return Quantity{}, fmt.Errorf("quantity amount must be > 0, got %f", amount)
  }
  return Quantity{Amount: amount, Unit: unit}, nil
}
 
// => Add rejects mixed units — adding kg to each is a domain error, not a programmer error.
func (q Quantity) Add(other Quantity) (Quantity, error) {
  // => Unit comparison catches semantic mismatches before arithmetic.
  if q.Unit != other.Unit {
    return Quantity{}, fmt.Errorf("unit mismatch: %s + %s", q.Unit, other.Unit)
  }
  return Quantity{Amount: q.Amount + other.Amount, Unit: q.Unit}, nil
}

Key takeaway: Bundle quantity and its unit of measure in a single value object. Unit-mismatch arithmetic becomes a caught domain error, not a silent numerical corruption.


Example 7: LineItem Value Object

A line item aggregates the product description, quantity ordered, and unit price into a single coherent concept. Its total price — quantity times unit price — drives the purchase order value. Validating at construction (non-empty description, positive quantity) ensures no garbage line items enter the system.

classDiagram
  class LineItem {
    +Id LineItemId
    +Description string
    +Qty Quantity
    +UnitPrice Money
    +New(desc, qty, price) LineItem
    +TotalPrice() Money
  }
  class Quantity {
    +Amount float64
    +Unit Unit
  }
  class Money {
    +amountCents int64
    +Currency string
  }
  LineItem --> Quantity
  LineItem --> Money
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class LineItem:::blue
  class Quantity:::teal
  class Money:::teal
// => LineItemId scopes the ID to line items — prevents swapping with other ID types.
type LineItemId string
 
// => LineItem is a value object: identity is by position in the PO, not by an entity ID.
// => The LineItemId field scopes uniqueness within the PO — not a global entity identity.
type LineItem struct {
  Id          LineItemId
  Description string
  Qty         Quantity
  UnitPrice   Money
}
 
// => NewLineItem validates inputs before construction — no invalid line items in domain.
func NewLineItem(desc string, qty Quantity, price Money) (LineItem, error) {
  // => Empty description is rejected — PO audits require identifiable line items.
  if strings.TrimSpace(desc) == "" {
    return LineItem{}, fmt.Errorf("line item description must not be empty")
  }
  // => Price currency must be set — zero money with empty currency is invalid.
  if price.Currency == "" {
    return LineItem{}, fmt.Errorf("line item unit price must have a currency")
  }
  return LineItem{
    // => uuid.New() generates a unique ID scoped to this line item.
    Id:          LineItemId(uuid.New().String()),
    Description: desc,
    Qty:         qty,
    UnitPrice:   price,
  }, nil
}
 
// => TotalPrice multiplies quantity by unit price — the key financial aggregate.
// => Returns (Money, error) because Multiply may return an error.
func (li LineItem) TotalPrice() (Money, error) {
  // => Multiply scales the unit price by the quantity amount.
  return li.UnitPrice.Multiply(li.Qty.Amount)
}

Key takeaway: A LineItem is a value object that bundles the data needed to compute the PO's total value. Validation at construction eliminates invalid line items before they reach the aggregate.


Example 8: Address Value Object

A delivery address is a value object — two addresses are equal if all their fields match, and there is no concept of "the same address updating its postal code." If the delivery address changes, a new Address value is created. Validating the ISO 3166-1 alpha-2 country code at construction prevents unmappable addresses from reaching the logistics subsystem.

classDiagram
  class Address {
    +Street string
    +City string
    +PostalCode string
    +CountryCode string
    +New(street, city, postal, country) Address
    +Equal(other Address) bool
  }
 
  classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class Address:::purple
// => All fields exported — Address is a pure data container with no invariant methods.
// => CountryCode is the only validated field — other fields vary too much to validate generically.
type Address struct {
  Street      string
  City        string
  PostalCode  string
  // => CountryCode must be ISO 3166-1 alpha-2: "TH", "US", "GB", etc.
  CountryCode string
}
 
// => validCountryCodes is a sample set — in production, use a full ISO 3166 dataset.
var validCountryCodes = map[string]bool{
  "TH": true, "US": true, "GB": true, "SG": true, "MY": true,
}
 
// => NewAddress validates the country code — other fields trusted from the UI layer.
func NewAddress(street, city, postal, country string) (Address, error) {
  // => Two-character uppercase check is the first guard — before map lookup.
  if len(country) != 2 {
    return Address{}, fmt.Errorf("country code must be 2 chars, got %q", country)
  }
  // => Map lookup confirms the code is a known country.
  if !validCountryCodes[strings.ToUpper(country)] {
    return Address{}, fmt.Errorf("unknown country code: %q", country)
  }
  // => Street and city must not be empty — delivery cannot route to a blank address.
  if strings.TrimSpace(street) == "" || strings.TrimSpace(city) == "" {
    return Address{}, fmt.Errorf("street and city must not be empty")
  }
  return Address{Street: street, City: city, PostalCode: postal, CountryCode: strings.ToUpper(country)}, nil
}
 
// => Equal compares all fields — value object equality by structural comparison.
// => Go struct == works here because all fields are comparable strings.
func (a Address) Equal(other Address) bool {
  return a == other
}

Key takeaway: Address is a value object — structural equality across all fields. Country code validation at construction prevents unmappable delivery addresses from entering the domain.


Example 9: DateRange Value Object

Procurement validity windows — PO expiry dates, delivery windows, contract periods — are naturally modelled as date ranges. A range where start >= end is nonsensical and should be rejected at construction. The Contains and Overlaps methods express business queries (is today within the delivery window? do two POs overlap?) as domain-layer operations rather than scattered date comparisons.

classDiagram
  class DateRange {
    +Start time.Time
    +End time.Time
    +New(start, end) DateRange
    +Contains(t time.Time) bool
    +Overlaps(other DateRange) bool
    +Duration() time.Duration
  }
 
  classDef brown fill:#CA9161,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class DateRange:::brown
// => time.Time fields — timezone-aware, suitable for cross-regional procurement.
type DateRange struct {
  Start time.Time
  End   time.Time
}
 
// => NewDateRange enforces start < end — an open or reversed range is invalid.
func NewDateRange(start, end time.Time) (DateRange, error) {
  // => After() not AfterOrEqual() — start and end cannot be the same instant.
  if !start.Before(end) {
    return DateRange{}, fmt.Errorf("start must be before end: %v >= %v", start, end)
  }
  return DateRange{Start: start, End: end}, nil
}
 
// => Contains checks whether a point in time falls within the range.
// => Used for: is today within the PO delivery window?
func (dr DateRange) Contains(t time.Time) bool {
  // => !t.Before(dr.Start) means t >= Start; t.Before(dr.End) means t < End.
  return !t.Before(dr.Start) && t.Before(dr.End)
}
 
// => Overlaps detects whether two ranges share any time — duplicate PO detection.
func (dr DateRange) Overlaps(other DateRange) bool {
  // => Two ranges overlap if neither ends before the other starts.
  return dr.Start.Before(other.End) && other.Start.Before(dr.End)
}
 
// => Duration returns the length of the range for SLA and reporting calculations.
func (dr DateRange) Duration() time.Duration {
  return dr.End.Sub(dr.Start)
}

Key takeaway: Encode date range logic — contains, overlaps — inside the value object. Business queries on dates become domain-layer operations, not ad-hoc comparisons scattered across services.


Example 10: Money Arithmetic Operations

A rich value object exposes domain-meaningful operations rather than forcing callers to perform arithmetic on the underlying representation. Multiply scales a unit price by a quantity. GreaterThan drives budget cap checks. IsZero validates that an invoice is not submitted for a zero amount. Centralising these in Money eliminates duplicated arithmetic across services and repositories.

classDiagram
  class Money {
    +amountCents int64
    +Currency string
    +Add(other Money) Money
    +Multiply(factor float64) Money
    +Equal(other Money) bool
    +GreaterThan(other Money) bool
    +IsZero() bool
  }
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class Money:::blue
// => Multiply scales money by a float factor — quantity × unit price.
// => Returns (Money, error) because negative factor is invalid.
func (m Money) Multiply(factor float64) (Money, error) {
  // => Negative factor would produce negative money — rejected.
  if factor < 0 {
    return Money{}, fmt.Errorf("multiply factor must be >= 0, got %f", factor)
  }
  // => Round to nearest cent after multiplication — prevents fractional cents.
  // => math.Round avoids truncation bias in accumulation scenarios.
  newCents := int64(math.Round(float64(m.amountCents) * factor))
  return Money{amountCents: newCents, Currency: m.Currency}, nil
}
 
// => Equal compares both amount and currency — two Money values are equal only if both match.
func (m Money) Equal(other Money) bool {
  return m.amountCents == other.amountCents && m.Currency == other.Currency
}
 
// => GreaterThan drives budget cap checks in the Approve transition.
// => Returns (bool, error) — comparing different currencies is an error.
func (m Money) GreaterThan(other Money) (bool, error) {
  if m.Currency != other.Currency {
    return false, fmt.Errorf("cannot compare %s and %s", m.Currency, other.Currency)
  }
  return m.amountCents > other.amountCents, nil
}
 
// => IsZero checks for a zero-value money amount — used in invoice validation.
func (m Money) IsZero() bool {
  return m.amountCents == 0
}

Key takeaway: Rich value objects centralise domain arithmetic. GreaterThan and Multiply on Money prevent ad-hoc arithmetic scattered across services, ensuring consistent currency handling everywhere.


Example 11: Value Object Equality — No Identity

The key difference between a value object and an entity is equality semantics. Two Money values are equal when their amounts and currencies match — there is no "which Money object" concept. Two Supplier entities are the same only if their IDs match, regardless of other fields. Getting this wrong causes subtle bugs: comparing two entities by value may yield false positives when state diverges temporarily.

graph TD
  A["Entity: Supplier"]:::blue
  B["Supplier A\nid='s-001'\nname='Acme'"]:::teal
  C["Supplier B\nid='s-001'\nname='Acme Corp'"]:::teal
  D{"same_identity?"}:::orange
  E["TRUE: same entity\n#40;id matches#41;"]:::teal
 
  F["Value Object: Money"]:::blue
  G["Money X\namount=1000\ncurrency=THB"]:::purple
  H["Money Y\namount=1000\ncurrency=THB"]:::purple
  I{"Equal?"}:::orange
  J["TRUE: equal values\n#40;all fields match#41;"]:::purple
 
  A --> B
  A --> C
  B --> D
  C --> D
  D --> E
 
  F --> G
  F --> H
  G --> I
  H --> I
  I --> J
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
// => Money is a value object — Go's == works because all fields are comparable.
// => Two Money values with equal amountCents and Currency are identical.
m1, _ := NewMoney(1000, "THB")
m2, _ := NewMoney(1000, "THB")
// => m1 == m2 is true — no "object identity" in Go for structs passed by value.
fmt.Println(m1 == m2) // => Output: true
 
// => For value objects with slice fields, == does NOT work — use reflect.DeepEqual.
// => Prefer explicit Equal() methods for consistency across all value objects.
fmt.Println(m1.Equal(m2)) // => Output: true (via explicit method)
 
// => Supplier is an entity — equality is by ID, not by all fields.
// => Two Supplier values with same id but different Name are the SAME entity.
s1 := Supplier{id: "s-001", Name: "Acme"}
s2 := Supplier{id: "s-001", Name: "Acme Corp"}
// => s1 == s2 is FALSE in Go (Name differs) — do not use == for entities.
fmt.Println(s1 == s2) // => Output: false (wrong for entities!)
// => Use SameIdentity for entities — compares id only.
fmt.Println(s1.SameIdentity(&s2)) // => Output: true (correct entity comparison)

Key takeaway: Value objects compare by all fields; entities compare by identity. In Rust, omitting PartialEq from entities makes incorrect comparisons a compile error. In Go, explicit SameIdentity methods enforce the convention.


Example 12: Immutability by Convention (Go) and Enforcement (Rust)

Value objects must not mutate — returning a new value preserves the original and prevents aliasing bugs. Go enforces this by convention: value receivers copy the struct, so no method on a value receiver can mutate the caller's copy. Rust can enforce it structurally by consuming self, making the old binding unavailable after the call.

sequenceDiagram
  participant Caller
  participant Money
 
  Caller->>Money: withCurrency(self, "USD")
  Note over Money: old Money consumed (Rust)<br/>or copied (Go value receiver)
  Money-->>Caller: new Money{currency: "USD"}
  Note over Caller: old binding unreachable (Rust)<br/>old variable unchanged (Go)
// => WithCurrency uses a VALUE receiver — Go copies the struct on call.
// => The original m is unchanged; a new Money is returned.
func (m Money) WithCurrency(c string) (Money, error) {
  // => Validate the new currency before constructing the new value.
  if len(c) != 3 {
    return Money{}, fmt.Errorf("currency must be 3 chars, got %q", c)
  }
  // => m is the copy — modifying it does not affect the caller's original.
  // => Return the modified copy as a new Money value.
  m.Currency = c
  return m, nil
}
 
// => Demonstration: original is unchanged after WithCurrency.
original, _ := NewMoney(500, "THB")
converted, _ := original.WithCurrency("USD")
// => original.Currency is still "THB" — value receiver copied it.
fmt.Println(original.Currency)  // => Output: THB
fmt.Println(converted.Currency) // => Output: USD

Key takeaway: Go value receivers copy the struct, enforcing immutability by convention. Rust consuming self makes it a compile error to use the old value after a transformation — a stronger guarantee with no runtime cost.


Entities (Examples 13–17)

Example 13: Supplier Entity

Entities have identity — two Supplier records are the same supplier as long as their IDs match, even if the name changes over time. This is the fundamental difference from value objects. Pointer receivers in Go signal that the struct is mutable (entities have lifecycle); value receivers signal immutable value objects. In the procurement domain, a supplier can be deactivated, renamed, or have its contact updated — all while remaining the same entity.

classDiagram
  class Supplier {
    -id SupplierId
    +Name string
    +Code SupplierCode
    +ContactEmail string
    +Active bool
    +CreatedAt time.Time
    +New(id, name, code, email) Supplier
    +SameIdentity(other) bool
    +Deactivate()
    +UpdateEmail(email string)
  }
 
  classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class Supplier:::teal
// => Unexported id field — identity is set at construction and never changed externally.
// => Exported Name, ContactEmail — mutable entity state that changes over lifecycle.
type Supplier struct {
  id           SupplierId
  Name         string
  Code         SupplierCode
  ContactEmail string
  Active       bool
  CreatedAt    time.Time
}
 
// => NewSupplier creates a new Supplier with a validated initial state.
// => Returns *Supplier (pointer) — entity mutations require pointer receiver methods.
func NewSupplier(id SupplierId, name string, code SupplierCode, email string) (*Supplier, error) {
  // => Name must be non-empty — a nameless supplier cannot be identified on documents.
  if strings.TrimSpace(name) == "" {
    return nil, fmt.Errorf("supplier name must not be empty")
  }
  return &Supplier{
    id:           id,
    Name:         name,
    Code:         code,
    ContactEmail: email,
    // => Active defaults to true — new suppliers are active unless explicitly deactivated.
    Active:    true,
    CreatedAt: time.Now(),
  }, nil
}
 
// => SameIdentity compares IDs only — not current Name or state.
// => Two Supplier pointers with the same id ARE the same entity.
func (s *Supplier) SameIdentity(other *Supplier) bool {
  return s.id == other.id
}
 
// => Deactivate uses a POINTER receiver — mutates the entity state in place.
// => Value receiver would mutate only the copy, leaving the original unchanged.
func (s *Supplier) Deactivate() {
  // => Active = false marks the supplier unavailable for new POs.
  s.Active = false
}
 
// => UpdateEmail is a domain operation — encapsulates the state change.
// => Pointer receiver ensures the change persists on the caller's instance.
func (s *Supplier) UpdateEmail(email string) error {
  if strings.TrimSpace(email) == "" {
    return fmt.Errorf("contact email must not be empty")
  }
  s.ContactEmail = email
  return nil
}
 
// => Id() exposes the private id for repository lookups — read-only accessor.
func (s *Supplier) Id() SupplierId {
  return s.id
}

Key takeaway: Entities have a stable identity that persists through state changes. Pointer receivers in Go and &mut self in Rust signal mutable entity lifecycle; SameIdentity / same_identity enforce correct equality semantics.


Example 14: Entity vs Value Object Distinction

The entity/value-object distinction drives every downstream design decision in DDD. Entities have lifecycle and mutable state; value objects are immutable snapshots interchangeable with any equal value. Getting the distinction wrong — treating a Supplier as a value object and comparing it by all fields — causes subtle identity bugs when state diverges between two references to the same entity.

classDiagram
  class Entity {
    +id ID
    +state mutable
    +SameIdentity(other) bool
    note: equality by ID
  }
  class ValueObject {
    +fields comparable
    +Equal(other) bool
    note: equality by all fields
  }
  class Supplier {
    -id SupplierId
    +Name string
    +SameIdentity(other) bool
  }
  class Money {
    +amountCents int64
    +Currency string
    +Equal(other) bool
  }
  Entity <|-- Supplier
  ValueObject <|-- Money
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class Entity:::blue
  class ValueObject:::orange
  class Supplier:::teal
  class Money:::teal
// => Entity: Supplier has an id field — identity comparison by id only.
// => Pointer receivers throughout signal mutable lifecycle.
s1 := &Supplier{id: "s-001", Name: "Acme"}
s2 := &Supplier{id: "s-001", Name: "Acme Corp"}
// => SameIdentity returns true — same supplier, different name at different points in time.
fmt.Println(s1.SameIdentity(s2)) // => Output: true
 
// => Value object: Money has no id — equality by amountCents + Currency.
// => Value receivers throughout signal immutable snapshot semantics.
m1, _ := NewMoney(500, "THB")
m2, _ := NewMoney(500, "THB")
// => Go struct == works for comparable structs — no explicit Equal needed.
fmt.Println(m1 == m2) // => Output: true
 
// => Convention in Go: pointer receiver = entity, value receiver = value object.
// => This is a code-reading signal, not enforced by the compiler.
// => Pointer receiver enables mutation; value receiver guarantees copy semantics.

Key takeaway: Entities compare by identity; value objects compare by all fields. In Rust, omitting PartialEq from entity structs makes incorrect structural comparisons a compile error.


Example 15: PurchaseOrder Entity (Basic Structure)

A PurchaseOrder is an entity with a rich lifecycle — it progresses through states, accumulates line items, and references a supplier by ID. Storing a SupplierId (not a *Supplier pointer) enforces the DDD rule that cross-aggregate references must be by identity only. No external code should interact with LineItems directly — all mutations route through the aggregate root's methods.

classDiagram
  class PurchaseOrder {
    -id PurchaseOrderId
    +SupplierId SupplierId
    +Status POStatus
    +LineItems []LineItem
    +CreatedAt time.Time
    +UpdatedAt time.Time
    +New(id, supplierId) PurchaseOrder
    +AddLineItem(item) error
    +Submit() error
    +Approve(approvedBy, level) error
  }
  class POStatus {
    <<enumeration>>
    Draft
    Submitted
    ApprovalPending
    Issued
  }
  class LineItem {
    +Id LineItemId
    +Description string
    +Qty Quantity
    +UnitPrice Money
  }
  PurchaseOrder --> POStatus
  PurchaseOrder "1" --> "*" LineItem
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class PurchaseOrder:::blue
  class POStatus:::orange
  class LineItem:::teal
// => PurchaseOrder is the aggregate root — all mutations route through its methods.
// => id is unexported — set at construction, immutable thereafter.
type PurchaseOrder struct {
  id         PurchaseOrderId
  // => SupplierId is exported for read access — cross-aggregate reference by ID only.
  // => No *Supplier field — DDD prohibits cross-aggregate object references.
  SupplierId SupplierId
  // => Status starts as Draft — only valid initial state.
  Status     POStatus
  // => LineItems owned by PO — no external code mutates this slice directly.
  LineItems  []LineItem
  CreatedAt  time.Time
  // => UpdatedAt tracks the last mutation — set on every state-changing method.
  UpdatedAt  time.Time
}
 
// => NewPurchaseOrder creates a Draft PO — the only valid initial state.
// => No line items at construction — they are added via AddLineItem.
func NewPurchaseOrder(id PurchaseOrderId, supplierID SupplierId) *PurchaseOrder {
  now := time.Now()
  return &PurchaseOrder{
    id:         id,
    SupplierId: supplierID,
    // => Draft is the only valid starting status — no other entry point.
    Status:     Draft,
    LineItems:  []LineItem{},
    CreatedAt:  now,
    UpdatedAt:  now,
  }
}
 
// => Id() exposes the private id — needed by repositories and event publishers.
func (po *PurchaseOrder) Id() PurchaseOrderId {
  return po.id
}

Key takeaway: The PurchaseOrder aggregate root owns its LineItems and holds only a SupplierId reference across the aggregate boundary. All mutations route through its exported methods — no direct field manipulation from outside.


Example 16: GoodReceiptNote Entity

A Good Receipt Note records the physical arrival of goods — it is a separate entity from the purchase order that triggered the delivery. The GRN references the PO by PurchaseOrderId only, following the DDD rule against cross-aggregate object references. The three-way match (PO line items vs GRN received items vs invoice amounts) is the core financial control in the procurement domain.

classDiagram
  class GoodReceiptNote {
    -id GRNId
    +POId PurchaseOrderId
    +SupplierId SupplierId
    +ReceivedItems []ReceivedItem
    +ReceivedAt time.Time
    +Finalized bool
    +New(id, poId, supplierId) GRN
    +AddReceivedItem(item) error
    +Finalize() error
  }
  class ReceivedItem {
    +LineItemId LineItemId
    +QtyReceived Quantity
    +ReceivedAt time.Time
  }
  GoodReceiptNote "1" --> "*" ReceivedItem
  GoodReceiptNote --> PurchaseOrderId : references by ID
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class GoodReceiptNote:::blue
  class ReceivedItem:::teal
// => GRNId scopes the identity to good receipt notes.
type GRNId string
 
// => ReceivedItem records what actually arrived for a given line item.
// => QtyReceived may differ from PO quantity — partial deliveries are common.
type ReceivedItem struct {
  // => LineItemId links back to the PO line item for three-way match.
  LineItemId  LineItemId
  QtyReceived Quantity
  ReceivedAt  time.Time
}
 
// => GoodReceiptNote references PO and Supplier by ID — no cross-aggregate pointers.
type GoodReceiptNote struct {
  id           GRNId
  // => POId links to the originating PO without importing the PO aggregate.
  POId         PurchaseOrderId
  SupplierId   SupplierId
  ReceivedItems []ReceivedItem
  ReceivedAt   time.Time
  // => Finalized = true means no more items can be added — receipt is closed.
  Finalized    bool
}
 
func NewGoodReceiptNote(id GRNId, poID PurchaseOrderId, supplierID SupplierId) *GoodReceiptNote {
  return &GoodReceiptNote{
    id:           id,
    POId:         poID,
    SupplierId:   supplierID,
    ReceivedItems: []ReceivedItem{},
    ReceivedAt:   time.Now(),
    Finalized:    false,
  }
}
 
// => AddReceivedItem rejects additions to a finalized GRN.
func (g *GoodReceiptNote) AddReceivedItem(item ReceivedItem) error {
  if g.Finalized {
    // => Finalized GRN is immutable — prevents post-close modifications.
    return fmt.Errorf("cannot add items to a finalized GRN")
  }
  g.ReceivedItems = append(g.ReceivedItems, item)
  return nil
}
 
// => Finalize closes the GRN — triggers three-way match in the application layer.
func (g *GoodReceiptNote) Finalize() error {
  if len(g.ReceivedItems) == 0 {
    return fmt.Errorf("cannot finalize a GRN with no received items")
  }
  g.Finalized = true
  return nil
}

Key takeaway: The GRN is a separate entity from the PO, linked by PurchaseOrderId only. Finalized is an invariant enforced by the entity's mutation methods — no external code can bypass it.


Example 17: Invoice Entity

An invoice represents the supplier's payment claim and has its own lifecycle independent from the PO that authorised the spend. The invoice references the PO by ID (cross-aggregate boundary), carries the claimed amount, and tracks its own status from Draft through Approved to Paid or Rejected. The SubmittedAt optional timestamp records when the invoice entered the approval queue.

stateDiagram-v2
  [*] --> Draft
  Draft --> Submitted : submit
  Submitted --> Approved : approve
  Submitted --> Rejected : reject
  Approved --> Paid : pay
  Rejected --> [*]
  Paid --> [*]
// => InvoiceId scopes identity to invoices — separate from PO and GRN IDs.
type InvoiceId string
 
// => InvoiceStatus tracks payment lifecycle — separate from POStatus.
type InvoiceStatus int
 
const (
  InvoiceDraft     InvoiceStatus = iota // 0
  InvoiceSubmitted                      // 1
  InvoiceApproved                       // 2
  InvoicePaid                           // 3
  InvoiceRejected                       // 4
)
 
// => Invoice references PO by ID — no cross-aggregate object reference.
type Invoice struct {
  id          InvoiceId
  // => POId links invoice to originating PO for three-way match.
  POId        PurchaseOrderId
  SupplierId  SupplierId
  // => Amount is the total invoice value — subject to three-way match check.
  Amount      Money
  DueDate     time.Time
  Status      InvoiceStatus
  // => SubmittedAt is nil until Submit() is called — pointer for optional time.
  SubmittedAt *time.Time
}
 
func NewInvoice(id InvoiceId, poID PurchaseOrderId, supplierID SupplierId, amount Money, dueDate time.Time) (*Invoice, error) {
  // => Zero-amount invoice is rejected — nothing to pay.
  if amount.IsZero() {
    return nil, fmt.Errorf("invoice amount must not be zero")
  }
  return &Invoice{
    id:         id,
    POId:       poID,
    SupplierId: supplierID,
    Amount:     amount,
    DueDate:    dueDate,
    // => InvoiceDraft is the only valid initial status.
    Status:     InvoiceDraft,
  }, nil
}
 
// => Submit transitions the invoice from Draft to Submitted.
// => Records SubmittedAt timestamp for SLA tracking.
func (inv *Invoice) Submit() error {
  if inv.Status != InvoiceDraft {
    return fmt.Errorf("can only submit a Draft invoice, current status: %d", inv.Status)
  }
  now := time.Now()
  inv.SubmittedAt = &now
  inv.Status = InvoiceSubmitted
  return nil
}

Key takeaway: Invoice has its own lifecycle independent from the PO. Option<DateTime<Utc>> / *time.Time for SubmittedAt documents optionality in the type system — not in comments.


Aggregate Root (Examples 18–22)

Example 18: PurchaseOrder as Aggregate Root — Protecting Invariants

The aggregate root is the gatekeeper: all mutations to the aggregate must pass through its methods, which enforce invariants before making any state changes. External code cannot access LineItems directly and add duplicates or invalid items. In the procurement domain, the PO aggregate root ensures that only Draft POs accept new line items, and that no duplicate line item IDs exist within a PO.

classDiagram
  class PurchaseOrder {
    -id PurchaseOrderId
    +Status POStatus
    -lineItems []LineItem
    +AddLineItem(item LineItem) error
    note: ONLY Draft POs accept new items
    note: Duplicate IDs rejected
  }
  class LineItem {
    +Id LineItemId
    +Description string
  }
  PurchaseOrder "guards" --> "*" LineItem : invariant protected
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class PurchaseOrder:::blue
  class LineItem:::teal
// => AddLineItem enforces two invariants before mutation:
// => 1. Only Draft POs accept new line items.
// => 2. No duplicate line item IDs within a PO.
func (po *PurchaseOrder) AddLineItem(item LineItem) error {
  // => Status guard: Submitted, Issued, etc. cannot accept new items.
  if po.Status != Draft {
    return fmt.Errorf("can only add items to a Draft PO, current status: %s", po.Status)
  }
  // => Duplicate check: iterate existing items to find ID collision.
  for _, existing := range po.LineItems {
    if existing.Id == item.Id {
      // => Duplicate ID is a domain error — two line items cannot share an ID.
      return fmt.Errorf("line item with ID %s already exists in PO %s", item.Id, po.id)
    }
  }
  // => Both invariants satisfied — safe to append.
  po.LineItems = append(po.LineItems, item)
  // => UpdatedAt records the time of this mutation for audit trail.
  po.UpdatedAt = time.Now()
  return nil
}
 
// => RemoveLineItem also guarded — only Draft POs allow item removal.
func (po *PurchaseOrder) RemoveLineItem(id LineItemId) error {
  if po.Status != Draft {
    return fmt.Errorf("can only remove items from a Draft PO")
  }
  for i, item := range po.LineItems {
    if item.Id == id {
      // => Slice removal preserves order — item at index i is removed.
      po.LineItems = append(po.LineItems[:i], po.LineItems[i+1:]...)
      po.UpdatedAt = time.Now()
      return nil
    }
  }
  return fmt.Errorf("line item with ID %s not found in PO %s", id, po.id)
}

Key takeaway: The aggregate root enforces invariants on every mutation. No external code bypasses AddLineItem to append directly to LineItems — the slice is unexported in Go, owned exclusively in Rust.


Example 19: Submit for Approval

The Submit transition moves a Draft PO into the approval queue. Two preconditions must hold: the PO must be in Draft status (not already submitted), and it must contain at least one line item (an empty PO has no business value). Both checks happen inside the aggregate root — the application service does not need to re-check them.

stateDiagram-v2
  Draft --> Submitted : submit [lineItems > 0]
  Submitted --> ApprovalPending : route
  note right of Draft : Empty PO rejected\nNon-Draft PO rejected
// => Submit enforces two preconditions before transitioning status.
// => Application service calls Submit() — no direct status assignment.
func (po *PurchaseOrder) Submit() error {
  // => Only Draft POs can be submitted — prevents double-submission.
  if po.Status != Draft {
    return fmt.Errorf("can only submit a Draft PO, current status: %s", po.Status)
  }
  // => Empty PO has no business value — reject before queueing for approval.
  if len(po.LineItems) == 0 {
    return fmt.Errorf("cannot submit a PO with no line items")
  }
  // => Both preconditions satisfied — transition to Submitted.
  po.Status = Submitted
  // => UpdatedAt records transition time for SLA tracking.
  po.UpdatedAt = time.Now()
  return nil
}

Key takeaway: State transition methods encode preconditions as domain errors. The application service receives a clear error when a transition is invalid — no conditional logic needed outside the aggregate.


Example 20: Approve — Multi-Level Authorization Check

Approval enforces the budget cap associated with the approver's level. Before setting the status to Issued, the aggregate root computes the PO's total value and verifies it does not exceed the ApprovalLevel's budget cap. If it does, the approval is rejected — the PO must be escalated to a higher tier. The approvedBy field is stored for audit trail purposes.

stateDiagram-v2
  ApprovalPending --> Issued : approve [totalValue <= budgetCap]
  ApprovalPending --> ApprovalPending : approve [totalValue > budgetCap, escalate]
  note right of ApprovalPending : Budget cap checked\nAgainst ApprovalLevel
// => ApprovedBy is stored on the PO for audit trail — who approved, when.
// => Add approvedBy field to PurchaseOrder struct if not already present.
func (po *PurchaseOrder) Approve(approvedBy string, level ApprovalLevel) error {
  // => Only ApprovalPending POs can be approved.
  if po.Status != ApprovalPending {
    return fmt.Errorf("can only approve an ApprovalPending PO, current: %s", po.Status)
  }
  // => Compute total value — this is a domain operation on the aggregate.
  total, err := po.TotalValue()
  if err != nil {
    return fmt.Errorf("computing total value: %w", err)
  }
  // => Check total value against the approval level's budget cap.
  capMoney, err := NewMoney(level.BudgetCapCents(), total.Currency)
  if err != nil {
    return fmt.Errorf("constructing cap money: %w", err)
  }
  exceedsCAP, err := total.GreaterThan(capMoney)
  if err != nil {
    return fmt.Errorf("comparing values: %w", err)
  }
  if exceedsCAP {
    // => Total exceeds level cap — requires escalation to higher tier.
    return fmt.Errorf("PO total %s exceeds approval level cap %s", total, capMoney)
  }
  // => Budget cap satisfied — transition to Issued.
  po.Status = Issued
  po.UpdatedAt = time.Now()
  return nil
}

Key takeaway: Budget cap enforcement lives inside the aggregate root's Approve method — the only place where all required information (line items, approval level) is available simultaneously.


Example 21: Reject with Reason

Rejection terminates the current approval attempt with an auditable reason. A blank rejection reason is rejected itself — auditors must be able to understand why a PO was denied. After rejection, the PO is marked Cancelled; a new PO must be created if the requester wants to resubmit with corrections.

stateDiagram-v2
  ApprovalPending --> Cancelled : reject [reason non-empty]
  note right of ApprovalPending : Blank reason rejected\nReason stored for audit
// => RejectionNote stores the reason and who rejected — audit requirements.
// => Add RejectionNote field to PurchaseOrder struct.
type RejectionNote struct {
  Reason     string
  RejectedBy string
  RejectedAt time.Time
}
 
// => Reject transitions ApprovalPending → Cancelled with a mandatory reason.
func (po *PurchaseOrder) Reject(reason, rejectedBy string) error {
  // => Only ApprovalPending POs can be rejected.
  if po.Status != ApprovalPending {
    return fmt.Errorf("can only reject an ApprovalPending PO, current: %s", po.Status)
  }
  // => Blank reason is itself invalid — audit trail requires meaningful rejection notes.
  if strings.TrimSpace(reason) == "" {
    return fmt.Errorf("rejection reason must not be empty")
  }
  // => Store rejection details before changing status — defensive ordering.
  po.RejectionNote = &RejectionNote{
    Reason:     reason,
    RejectedBy: rejectedBy,
    RejectedAt: time.Now(),
  }
  // => Cancelled is the terminal state for rejected POs.
  po.Status = Cancelled
  po.UpdatedAt = time.Now()
  return nil
}

Key takeaway: Rejection requires a non-empty reason stored as an auditable RejectionNote. Blank reasons are rejected by the aggregate root — the audit trail is an aggregate invariant.


Example 22: Budget Invariant — TotalValue

The TotalValue method is the aggregate root's internal computation that sums all line item totals. It is called by Approve to enforce the budget cap invariant. Centralising this calculation inside the aggregate ensures all code paths use the same total — there is no risk of an application service computing a different total and reaching a different conclusion than the approval guard.

classDiagram
  class PurchaseOrder {
    -lineItems []LineItem
    +TotalValue() Money
    note: called by Approve
  }
  class LineItem {
    +TotalPrice() Money
  }
  PurchaseOrder "1" --> "*" LineItem : sums TotalPrice
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class PurchaseOrder:::blue
  class LineItem:::teal
// => TotalValue sums all line item totals — the aggregate's financial representation.
// => Returns (Money, error) because TotalPrice can fail on currency inconsistency.
func (po *PurchaseOrder) TotalValue() (Money, error) {
  // => Empty PO has zero total — valid for read but not for submission.
  if len(po.LineItems) == 0 {
    // => Zero money in an empty currency — caller must handle this edge case.
    return Money{amountCents: 0, Currency: ""}, nil
  }
  // => Start accumulation with the first item's total.
  total, err := po.LineItems[0].TotalPrice()
  if err != nil {
    return Money{}, fmt.Errorf("computing line item 0 total: %w", err)
  }
  // => Iterate remaining items — Add will error if currencies differ.
  for i, item := range po.LineItems[1:] {
    itemTotal, err := item.TotalPrice()
    if err != nil {
      return Money{}, fmt.Errorf("computing line item %d total: %w", i+1, err)
    }
    total, err = total.Add(itemTotal)
    if err != nil {
      // => Currency mismatch across line items is an aggregate data error.
      return Money{}, fmt.Errorf("summing line items: %w", err)
    }
  }
  return total, nil
}

Key takeaway: TotalValue is the canonical financial computation for the PO aggregate. By centralising it in the root, all callers — including Approve — use the same authoritative total.


Domain Events (Examples 23–25)

Example 23: DomainEvent Interface and POCreated

Domain events record facts that occurred in the domain — they are past-tense, immutable, and carry enough context for downstream consumers to act without querying back into the originating aggregate. POCreated records that a purchase order was created, who the supplier is, and when it happened. Multiple downstream systems (notification, audit log, analytics) can react to the same event without coupling to each other.

classDiagram
  class DomainEvent {
    <<interface>>
    +EventType() string
    +OccurredAt() time.Time
  }
  class POCreated {
    +POId PurchaseOrderId
    +SupplierId SupplierId
    +OccurredAtTime time.Time
    +EventType() string
    +OccurredAt() time.Time
  }
  class POApproved {
    +POId PurchaseOrderId
    +ApprovedBy string
    +TotalValue Money
    +EventType() string
    +OccurredAt() time.Time
  }
  class POCancelled {
    +POId PurchaseOrderId
    +Reason string
    +EventType() string
    +OccurredAt() time.Time
  }
  DomainEvent <|.. POCreated
  DomainEvent <|.. POApproved
  DomainEvent <|.. POCancelled
 
  classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
  classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
  class DomainEvent:::blue
  class POCreated:::teal
  class POApproved:::teal
  class POCancelled:::teal
// => DomainEvent interface — minimal contract for all domain events.
// => EventType() returns a string tag used for routing in the event bus.
type DomainEvent interface {
  EventType() string
  // => OccurredAt() is immutable — events are facts, not mutable state.
  OccurredAt() time.Time
}
 
// => POCreated records the fact that a PO was created — past tense naming.
// => Past tense (Created, not Create) signals this is a fact, not a command.
type POCreated struct {
  POId           PurchaseOrderId
  SupplierId     SupplierId
  // => OccurredAtTime stored as a field — not recomputed on each call.
  OccurredAtTime time.Time
}
 
// => EventType returns a stable string tag for event routing.
// => String constant prevents typos in bus routing logic.
func (e POCreated) EventType() string { return "po.created" }
 
// => OccurredAt satisfies the DomainEvent interface — returns the stored time.
func (e POCreated) OccurredAt() time.Time { return e.OccurredAtTime }
 
// => POApproved carries the approved total for downstream finance systems.
type POApproved struct {
  POId           PurchaseOrderId
  ApprovedBy     string
  TotalValue     Money
  OccurredAtTime time.Time
}
 
func (e POApproved) EventType() string  { return "po.approved" }
func (e POApproved) OccurredAt() time.Time { return e.OccurredAtTime }

Key takeaway: Domain events are past-tense, immutable facts. The DomainEvent interface/trait requires only an event type tag and a timestamp — enough for routing and ordering without coupling consumers to aggregate internals.


Example 24: Raising Events in the Aggregate

Aggregates collect domain events internally during state transitions and expose them for the application service to drain and publish after the aggregate is persisted. This ordering is critical: events must not be published until the aggregate is safely saved — publishing before persistence creates ghost events that reference entities that do not exist in the database.

sequenceDiagram
  participant AppService
  participant PurchaseOrder
  participant Repository
  participant EventBus
 
  AppService->>PurchaseOrder: NewPurchaseOrder(id, supplierId)
  PurchaseOrder->>PurchaseOrder: emit POCreated (internal)
  AppService->>Repository: Save(po)
  Repository-->>AppService: saved
  AppService->>PurchaseOrder: DomainEvents()
  PurchaseOrder-->>AppService: [POCreated]
  AppService->>EventBus: Publish(POCreated)
  AppService->>PurchaseOrder: ClearEvents()
// => uncommittedEvents collects events raised during the current transaction.
// => They are NOT published until the application service drains them after save.
// => Add this field to the PurchaseOrder struct definition.
//    uncommittedEvents []DomainEvent
 
// => Emit appends an event to the internal buffer.
// => Private method — only aggregate methods call Emit.
func (po *PurchaseOrder) emit(event DomainEvent) {
  po.uncommittedEvents = append(po.uncommittedEvents, event)
}
 
// => DomainEvents exposes the uncommitted events for the application service.
// => Returns a copy — caller cannot mutate the internal slice.
func (po *PurchaseOrder) DomainEvents() []DomainEvent {
  result := make([]DomainEvent, len(po.uncommittedEvents))
  copy(result, po.uncommittedEvents)
  return result
}
 
// => ClearEvents is called by the application service after successful publish.
// => If publish fails, events are NOT cleared — they will be retried.
func (po *PurchaseOrder) ClearEvents() {
  po.uncommittedEvents = nil
}
 
// => NewPurchaseOrder updated to emit POCreated on construction.
func NewPurchaseOrder(id PurchaseOrderId, supplierID SupplierId) *PurchaseOrder {
  now := time.Now()
  po := &PurchaseOrder{
    id:         id,
    SupplierId: supplierID,
    Status:     Draft,
    LineItems:  []LineItem{},
    CreatedAt:  now,
    UpdatedAt:  now,
  }
  // => Emit the creation event immediately — before returning to the caller.
  po.emit(POCreated{
    POId:           id,
    SupplierId:     supplierID,
    OccurredAtTime: now,
  })
  return po
}

Key takeaway: Aggregate events are buffered internally and published by the application service only after persistence succeeds. ClearEvents is called post-publish — if publish fails, events survive for retry.


Example 25: Event Handler Pattern

Event handlers subscribe to specific event types and react with side effects — email notification, audit log entries, downstream system updates. Handlers depend on interfaces (ports), not concrete types, so they can be tested with fakes. Multiple handlers can register for the same event, enabling decoupled cross-cutting concerns like notifications and analytics.

sequenceDiagram
  participant EventBus
  participant POCreatedNotifier
  participant SupplierRepository
  participant Notifier
 
  EventBus->>POCreatedNotifier: Handle(POCreated)
  POCreatedNotifier->>SupplierRepository: FindById(supplierId)
  SupplierRepository-->>POCreatedNotifier: Supplier
  POCreatedNotifier->>Notifier: Send(supplierEmail, message)
  Notifier-->>POCreatedNotifier: sent
  POCreatedNotifier-->>EventBus: nil (success)
// => EventHandler is a small interface — one method, one responsibility.
// => context.Context enables cancellation and deadline propagation.
type EventHandler interface {
  Handle(ctx context.Context, event DomainEvent) error
}
 
// => Notifier is a port — application code depends on this interface, not SMTP.
type Notifier interface {
  Send(ctx context.Context, to, subject, body string) error
}
 
// => SupplierRepository is a port — application code depends on this interface.
type SupplierRepository interface {
  FindById(ctx context.Context, id SupplierId) (*Supplier, error)
}
 
// => POCreatedNotifier handles the po.created event — sends supplier notification.
// => Dependencies are interfaces — testable with fakes, no concrete coupling.
type POCreatedNotifier struct {
  notifier     Notifier
  supplierRepo SupplierRepository
}
 
func NewPOCreatedNotifier(n Notifier, sr SupplierRepository) *POCreatedNotifier {
  return &POCreatedNotifier{notifier: n, supplierRepo: sr}
}
 
func (h *POCreatedNotifier) Handle(ctx context.Context, event DomainEvent) error {
  // => Type assert to concrete event — handler only processes its event type.
  created, ok := event.(POCreated)
  if !ok {
    // => Ignore events that are not POCreated — idempotent no-op.
    return nil
  }
  // => Look up the supplier to get their contact email.
  supplier, err := h.supplierRepo.FindById(ctx, created.SupplierId)
  if err != nil {
    return fmt.Errorf("finding supplier for notification: %w", err)
  }
  // => Send the notification via the Notifier port.
  return h.notifier.Send(ctx, supplier.ContactEmail,
    "New Purchase Order Created",
    fmt.Sprintf("PO %s has been created and awaits your confirmation.", created.POId))
}

Key takeaway: Event handlers depend on ports (interfaces/traits), not concrete infrastructure. can_handle enables the event bus to route efficiently; async_trait enables real-world async side effects in Rust handlers.

Last updated May 23, 2026

Command Palette

Search for a command to run...