Entities, Value Objects, Aggregates -- One Transactional Boundary Rule
What This Concept Is
Tactical DDD gives you three building blocks for modeling state inside a bounded context:
- Value Object (VO). Immutable, identity-less, compared by value. Encodes a concept that is fully defined by its attributes. Examples:
Money(amount, currency),Weight(kg),Address(street, city, zip),DateRange(from, to),ServiceClass("GROUND"). - Entity. Mutable over time, identified by an ID independent of its attributes. Examples:
Shipment#AWB784199288734,Invoice#INV-2026-07-00412. - Aggregate. A cluster of entities and value objects forming a consistency boundary. It has one root entity (the aggregate root) that the outside world holds a reference to; all access goes through the root.
The one transactional boundary rule: one transaction changes state in one aggregate only. If a change must span two aggregates, you model it as two transactions coordinated by an event or a saga (see concept 11 and CQRS in concept 13).
Why the rule? Two reasons:
- Invariants. An aggregate exists because some rule must always hold across its parts (e.g., "shipment total weight ≤ carrier limit"). Enforcing an invariant across two transactional units in a distributed system is almost impossible. Keeping invariants local keeps them real.
- Concurrency. Optimistic concurrency control, locks, and version counters work per aggregate. If you modify two aggregates in one transaction, you couple them for locking.
Companion rule: aggregates reference other aggregates by ID only. Inside Invoice, don't hold a Shipment object; hold a ShipmentId. Load the other aggregate in the application layer.
Why It Matters Here
Most "clever" enterprise code bugs trace back to broken invariants or hidden transactional coupling. The aggregate pattern is the DDD community's antidote. Once you have it:
- invariants have a single place they live
- you can reason about concurrency per aggregate
- you can shard by aggregate ID
- you can version or event-source individual aggregates
- testing business logic becomes: feed commands to an aggregate, assert on events
Getting aggregates wrong -- especially making them too big -- is the single most common DDD implementation failure.
Concrete Example
Case: Parcel Shipping -- the Shipment aggregate
The sketch
┌─────────────────────────────────────────────────────────┐
│ Aggregate: Shipment (root: Shipment entity) │
│ │
│ Identity: │
│ • ShipmentId (AWB string) ← never changes │
│ │
│ Entities (inside): │
│ • Parcel (ParcelId, Weight, Dimensions, Contents) │
│ - many Parcels per Shipment │
│ │
│ Value Objects: │
│ • ServiceClass ("GROUND" | "EXPRESS" | "SAMEDAY") │
│ • Money (amount, currency) │
│ • Weight (kg) │
│ • Dimensions (l, w, h cm) │
│ • Address (street, city, postal, country) │
│ • DateRange (pickupWindow) │
│ • ShipmentStatus (enum-like VO with transitions) │
│ │
│ References (by ID only): │
│ • CustomerId │
│ • RateSnapshotId (from Pricing) │
│ • CarrierId (assigned after booking) │
│ • InvoiceId? │
│ │
│ Invariants enforced by the root: │
│ 1. Total parcel weight ≤ service-class max │
│ 2. Origin country must be supported by CarrierId │
│ 3. Cannot transition to DELIVERED unless >= 1 scan │
│ 4. Cannot cancel after LABEL_PRINTED │
│ 5. ServiceClass immutable after booking │
└─────────────────────────────────────────────────────────┘
The code
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import List, Optional
import uuid
# --- Value objects: immutable, compared by value ---
@dataclass(frozen=True)
class Money:
amount_cents: int
currency: str # ISO 4217
def __post_init__(self):
if len(self.currency) != 3:
raise ValueError("Currency must be ISO 4217")
@dataclass(frozen=True)
class Weight:
kg: Decimal
def __post_init__(self):
if self.kg <= 0:
raise ValueError("Weight must be positive")
@dataclass(frozen=True)
class Dimensions:
length_cm: Decimal
width_cm: Decimal
height_cm: Decimal
@dataclass(frozen=True)
class Address:
street: str
city: str
postal_code: str
iso_country: str
class ServiceClass(str, Enum):
GROUND = "GROUND"
EXPRESS = "EXPRESS"
SAMEDAY = "SAMEDAY"
# Status is modeled as a value object with explicit allowed transitions.
class ShipmentStatus(str, Enum):
BOOKED = "BOOKED"
LABEL_PRINTED = "LABEL_PRINTED"
PICKED_UP = "PICKED_UP"
IN_TRANSIT = "IN_TRANSIT"
DELIVERED = "DELIVERED"
CANCELLED = "CANCELLED"
EXCEPTION = "EXCEPTION"
# --- Entity *inside* the aggregate ---
@dataclass
class Parcel:
parcel_id: str
weight: Weight
dimensions: Dimensions
contents_description: str
# --- Aggregate root ---
@dataclass
class Shipment:
shipment_id: str # AWB
customer_id: str # reference by ID
service_class: ServiceClass # immutable after booking
origin: Address
destination: Address
parcels: List[Parcel] = field(default_factory=list)
rate_snapshot_id: Optional[str] = None
carrier_id: Optional[str] = None # reference by ID
status: ShipmentStatus = ShipmentStatus.BOOKED
version: int = 0 # optimistic concurrency
_pending_events: list = field(default_factory=list)
# --- Factory / constructor command ---
@classmethod
def book(cls, customer_id, service_class, origin, destination, parcels, rate_snapshot_id):
s = cls(
shipment_id=_new_awb(),
customer_id=customer_id,
service_class=service_class,
origin=origin,
destination=destination,
parcels=list(parcels),
rate_snapshot_id=rate_snapshot_id,
)
s._check_weight_invariant() # invariant 1
s._pending_events.append(ShipmentBooked(s.shipment_id, customer_id, datetime.utcnow()))
return s
# --- Commands ---
def assign_carrier(self, carrier_id, supports_country):
if self.status != ShipmentStatus.BOOKED:
raise InvalidStateError("Carrier can only be assigned while BOOKED.")
if not supports_country(carrier_id, self.origin.iso_country):
raise DomainError("Carrier does not serve origin country.") # invariant 2
self.carrier_id = carrier_id
self._pending_events.append(CarrierAssigned(self.shipment_id, carrier_id))
def print_label(self):
if self.status != ShipmentStatus.BOOKED or self.carrier_id is None:
raise InvalidStateError("Must be BOOKED with a carrier to print label.")
self.status = ShipmentStatus.LABEL_PRINTED
self._pending_events.append(LabelPrinted(self.shipment_id))
def cancel(self, reason: str):
if self.status in (ShipmentStatus.LABEL_PRINTED,
ShipmentStatus.PICKED_UP,
ShipmentStatus.IN_TRANSIT,
ShipmentStatus.DELIVERED):
raise InvalidStateError("Cannot cancel after label printed.") # invariant 4
self.status = ShipmentStatus.CANCELLED
self._pending_events.append(ShipmentCancelled(self.shipment_id, reason))
def record_delivered(self, at: datetime, has_scan: bool):
if not has_scan:
raise DomainError("Delivery requires at least one carrier scan.") # invariant 3
self.status = ShipmentStatus.DELIVERED
self._pending_events.append(ShipmentDelivered(self.shipment_id, at))
# --- Invariant helpers ---
def _check_weight_invariant(self):
total_kg = sum((p.weight.kg for p in self.parcels), Decimal(0))
limit = _weight_limit_for(self.service_class)
if total_kg > limit:
raise DomainError(f"Total weight {total_kg}kg exceeds {self.service_class.value} limit {limit}kg.")
# --- Domain events emitted by the aggregate ---
@dataclass(frozen=True)
class ShipmentBooked:
shipment_id: str
customer_id: str
at: datetime
@dataclass(frozen=True)
class CarrierAssigned:
shipment_id: str
carrier_id: str
@dataclass(frozen=True)
class LabelPrinted:
shipment_id: str
@dataclass(frozen=True)
class ShipmentCancelled:
shipment_id: str
reason: str
@dataclass(frozen=True)
class ShipmentDelivered:
shipment_id: str
at: datetime
Everything outside talks to Shipment via commands (book, assign_carrier, print_label, …). No one reaches in to mutate parcels directly. Invariants 1-4 are all enforced in the root.
The transactional boundary
# One transaction: one aggregate
with uow: # unit of work / db transaction
shipment = shipments.load(shipment_id) # one aggregate
shipment.print_label() # one command
shipments.save(shipment) # one version bump
bus.publish(shipment.pull_events()) # events leave, to be handled elsewhere
# transaction commits here
If printing a label must also decrement a label-stock counter owned by the Inventory aggregate, you do not modify both in one transaction. You:
- commit the
Shipmenttransaction withLabelPrintedevent - publish the event
- a handler reads the event and issues a command
DecrementLabelStockto the Inventory aggregate in its own transaction
This is the bridge to concept 11 (domain events) and concept 13 (CQRS).
What makes a bad aggregate
- "One big aggregate per bounded context" -- makes everything contend on one write lock
Customerthat contains all orders, invoices, shipments -- a truly huge object graph; every customer save is a bomb- aggregates that reference other aggregate objects directly (not by ID) -- saving one saves the other's stale state
- invariants enforced in the application layer instead of the root -- eventually someone bypasses
Smaller is usually better. If you can make the invariants hold with a smaller aggregate, do.
Common Confusion / Misconception
"Entities are always database rows." They can map to rows, but identity comes from the domain, not the database. Money might be stored as two columns and still be a value object.
"Value objects are just DTOs." DTOs carry data; VOs carry domain meaning. A VO has invariants in its constructor (currency must be 3 letters, weight must be positive). A DTO has no behavior.
"I'll make everything immutable to be safe." Entities mutate -- that is what distinguishes them from value objects. Forcing immutability on entities turns natural code into awkward rewiring.
"Aggregate = a service." No. A service (application layer) loads an aggregate, calls a command on it, and saves it. The aggregate has business logic; the service has orchestration.
"My aggregate is big because the business is complex." Usually the business is fine; the model is just collecting unrelated things. Split by invariant, not by "seems related."
"Referencing other aggregates by ID forces N+1 queries." Only if you load them wrong. The application layer chooses whether to batch-load, cache, or skip. That choice is orthogonal to aggregate design and belongs outside the domain model.
How To Use It
To design an aggregate:
- Start with the domain event timeline (from EventStorming).
- For each command that causes those events, ask: what invariant must hold before I emit the event?
- Group commands with overlapping invariants.
- The state those commands must read and write together is one aggregate.
- Pick a root entity (the thing clients refer to).
- Model the rest as value objects (immutable) or child entities (only if they have their own identity inside the aggregate).
- Reference other aggregates by ID.
- Put invariant checks inside the root's command methods. No exceptions.
- Emit domain events from the commands.
- Choose the smallest aggregate that still enforces the invariants. Split if it grows.
Check Yourself
- Give an example of a value object and state its invariants.
- Why do aggregates reference each other by ID instead of by object reference?
- An engineer proposes making
Customerthe aggregate root and puttingOrder,Shipment, andInvoiceas child entities inside it. State two problems with this.
Mini Drill or Application
Design an aggregate for an e-commerce checkout.
Given:
- checkout holds line items, a shipping address, a chosen payment method, and (after submit) a placed order with an order ID
- business rules: total must be ≥ $0; promo-code can reduce the total but total cannot go below $0; a checkout can only be submitted once; once submitted, it becomes an
Orderaggregate
Produce:
- The aggregate sketch (root, entities, VOs, references, invariants).
- Code (any language) for the commands
add_item,apply_promo,change_address,submit. - Name each invariant and show where it is enforced.
- Identify at least one operation that would cross an aggregate boundary and show how you would coordinate it via an event rather than a transaction.