Entities, Aggregates, Value Types, and State Machines are the structural core of the domain model. An entity is a thing with identity and a lifecycle. An aggregate is a consistency cluster with a root that controls access. A value type is an immutable, attribute-defined concept embedded inside entities. A state machine structures how an entity moves through its lifecycle one transition at a time. Together they give the model a shape that can carry behavior, invariants, and traceability back to requirements.
Entity
An Entity is a domain concept with a fixed identity and a lifecycle. The entity changes through time; its identity stays the same. Two entities are equal if and only if their identities are equal — attribute equality does not count.
An entity has five facets the modeler must define:
1. Structure — fields. An entity holds fields. Each field is typed as a primitive (String, Integer, Boolean, Float, Date, DateTime), a value type, or a reference to another entity. A value type used as a field is embedded: it is owned by the entity instance, has no independent identity, and shares no storage with other entities. The entity’s state — often named with a past participle (e.g., placed, delivered) — is the complete set of field values at a given point in the lifecycle.
2. Behavior — operations. An entity owns operations, named with verbs. Each operation takes the entity as its first explicit argument and returns the entity in its next state. Operations may emit events.
3. State machines. An entity may have one or more state machines that structure how operations drive transitions. When an entity has multiple state machines, they must govern disjoint aspects of the entity’s state — they cannot overlap.
4. Duplicate detection. An entity declares a duplicate detection predicate over candidate fields, evaluated through its repository. This says what counts as “the same entity already exists” for the domain — an idea that is strictly stronger than identity equality.
5. Repository. Each entity derives a repository with core operations (store, getById, remove, search) plus findByField operations deduced from use cases. If the entity belongs to an aggregate, only the aggregate root has a repository.
Naming: Entity types are named with a noun.
Example:
entity Order {
satisfies [REQ-ORD-002]
fields {
id: OrderId
customer: CustomerId
lines: LineItem[] // LineItem is a value type, embedded
total: Money // value type, embedded
placedAt: DateTime
}
operations {
place(order, customer, lines) -> Order
addLine(order, line) -> Order
cancel(order, reason) -> Order
}
duplicateDetection {
sameCustomerAndLines(customer, lines)
}
stateMachine placement { ... }
}
The Order entity holds embedded LineItem and Money value types, owns place/addLine/cancel operations, declares a duplicate-detection predicate, and carries a placement state machine for its lifecycle.
Read-only Entity (Master Data)
A Read-only Entity is an entity whose state is owned by another bounded context or an external system. Within this bounded context it is observable but never mutable — operations that change its state do not exist locally, and its repository exposes only read operations. Common examples are the master-data archetypes: Customer, Supplier, Product. They are referenced by higher-level operations of this context (e.g. an Order references a Product) but their lifecycle is governed elsewhere.
A read-only entity is structural, not behavioural. All entity rules apply except that it owns no operations: the rule “an entity owns operations named with verbs” is replaced by “a read-only entity owns no operations”. Domain operations defined locally on a read-only entity are forbidden.
Sync patterns. A read-only entity is kept in sync with its source through one of two patterns, declared explicitly:
- Synchronous lookup — this context issues a query to the upstream context every time it needs the entity’s current state. There is no local copy. Best when the data is small, freshness matters more than availability, and the upstream is reachable.
- Asynchronous projection — this context subscribes to the upstream context’s events and maintains a local read-model, updated each time a relevant event arrives. Best when latency-tolerant freshness is acceptable, the upstream is high-volume, or local availability matters more than instant consistency.
Repository surface. A read-only entity’s repository derives only read operations — getById, findByField, search. There is no store, no delete, no update. Anything that would mutate state belongs in the source context, not here.
Example:
entity Product {
satisfies [REQ-CAT-001]
read-only
sourced from CatalogContext
synced via async-projection
fields {
id: ProductId
sku: SKU
name: String
listPrice: Money
}
}
Product is observable inside this bounded context — an Order can reference its id and read its listPrice — but no operation in this context can change its name or price. When CatalogContext emits ProductRenamed or ProductPriceUpdated, the local projection updates accordingly. The upstream is the only place where the product’s lifecycle is governed.
The read-only declaration also disciplines context-map decisions: a read-only entity is the structural fingerprint of an Anti-Corruption Layer or a Conformist relationship — both patterns flow upstream data into this context, and both must respect the “no local operations” rule.
Aggregate
An Aggregate is a group of entities with a designated root that enforces the integrity of the whole. All state changes pass through the root; every operation on any member of the cluster evaluates the aggregate’s invariants before committing. An aggregate is an entity — all entity rules apply, plus:
- Root. Exactly one entity in the cluster is the root. External code interacts only with the root.
- Contained entities. The cluster is 1..n entities, including the root. Non-root members have no repository — they are reached only through the root.
- Aggregate-level invariants. Invariants may span the whole cluster (e.g., “the sum of line-item quantities equals the declared total”). These are checked on every state mutation that reaches the root.
Example:
aggregate Booking {
satisfies [REQ-BKG-001]
root Booking
contains BookingSlot
contains BookingGuest
invariant noOverlappingSlots scope: aggregate
invariant guestCountWithinCapacity scope: aggregate
}
A Booking aggregate containing BookingSlot and BookingGuest children. A client cannot load or modify a BookingSlot directly — they must go through the Booking root, which enforces noOverlappingSlots and guestCountWithinCapacity before any change is persisted.
Value Type
A Value Type is an immutable domain concept defined entirely by its attributes. Two values are equal if and only if all their attributes are equal. Values measure, quantify, or describe a domain thing. Values may be ordered; values compose — a value can contain other values.
Three rules define a value type:
Immutability. A value’s state never changes after construction. An operation on a value returns a new value rather than mutating the current one.
Transactional constructor. Construction is all-or-nothing: the value exists if all its fields are valid, otherwise creation is impossible. All creation-time constraints are enforced in the constructor. Once a value exists, it is guaranteed to be well-formed.
Embedded in entities. A value type can be embedded in one or more entities as a field type. When embedded, the value instance is owned by the entity — it has no independent identity or lifecycle. The same value type definition may appear as a field in many different entities: an Address value type can be embedded in both a Customer entity and a Warehouse entity, with no shared storage or identity.
Example:
value Money {
satisfies [REQ-FIN-001]
fields {
amount: Decimal
currency: CurrencyCode
}
constructor {
precondition amount >= 0
precondition currency in supportedCurrencies
}
operations {
plus(other: Money) -> Money // returns a new Money
times(factor: Decimal) -> Money
}
}
Money is immutable. Money.plus returns a new Money value; the original is never modified. Money can be embedded as the total field of Order, the price field of Product, the creditLimit field of Customer, and so on — one definition, many hosts.
Enums (Referential)
An Enum is a closed, named set of values used to constrain a field to a known finite range. The values may be primitive (a string or a number) or instances of an existing value type. Enums have no identity and no lifecycle of their own — they are purely referential. Their job is to name the legal values a field can hold, so a reader of the model knows the exhaustive choice space without leaving the model.
Two flavors:
1. Primitive-valued enums. The closed set is a list of labels. Useful when the values carry no internal structure beyond their name.
enum Severity { low, medium, high, critical }
enum PaymentMethodType { creditCard, debitCard, paypal, applePay, googlePay }
2. Value-type-valued enums. The closed set is a list of value-type instances. Useful when each value carries reference data (e.g., ISO codes, conversion factors, display labels) that other parts of the system need to read by name. When an enum’s values are value-type instances, the value type must declare a code field (typically named id, code, or symbol) that serves as the unambiguous reference — it is what other parts of the system use to refer to the value, and it stays stable as the value type’s other fields evolve.
Example. A Currency value type carries a code (the ISO 4217 alphabetic code), a precision (the default number of fraction digits), a display name (e.g. “US Dollar”), and a symbol (e.g. "$"). The Currencies enum holds every defined Currency value:
value Currency {
fields {
code: ISO4217Code // the code field — unambiguous reference
precision: Integer
displayName: String
symbol: String
}
}
enum Currencies references Currency {
USD, EUR, GBP, JPY, CHF, CAD, AUD, ...
}
value Amount {
fields {
value: Decimal
currency: Currency // typed by the value type
}
}
// elsewhere — set the currency by code
amount.currency = Currencies.USD
A field of type currency: Currency can be set with Currencies.USD — unambiguous, stable as the value type evolves, and discoverable from the model alone.
Naming. Enums are named with a noun, typically pluralized when they hold value-type instances (Currencies, Roles, Countries) and singular when they hold a closed set of primitive labels (Severity, PaymentMethodType).
Why enums sit between value types and behavior. A primitive-valued enum is a constrained string or number; a value-type-valued enum is a constrained selection from a value-type universe. In both cases the enum is structural — it tells the reader which values are legal for this field — and it has no behavior of its own. That is why enums live alongside value types in the structural layer of the domain model.
State Machine
A State Machine structures the lifecycle of an entity through an explicit set of states and transitions.
- One start state, zero or more final states. The start is where every instance begins; final states have no outgoing transitions.
- State-scoped invariants. Each state carries its own invariants — conditions that must hold while the entity occupies that state. State-scoped invariants are implicitly conjoined with entity-level invariants: both must hold simultaneously while the entity is in that state.
- Transitions connect states. Each transition has preconditions (guards — what must be true to take the transition) and postconditions (what must be true once taken).
- Entity operations trigger transitions. The transition is the effect of the operation. An operation may participate in transitions from multiple source states and therefore raise different events depending on where the entity was when the operation was invoked.
- Finite and enumerable. The set of valid transitions from each state is finite and listable.
Example:
stateMachine Ride.lifecycle {
belongs to: Ride
satisfies [REQ-RIDE-020]
start state requested
state driverEnRoute
state driverArrived
state inTransit
final state completed
final state cancelled
invariant (driverEnRoute) hasAssignedDriver
invariant (inTransit) hasPickedUpRider
invariant (completed) hasFareComputed
transition requested -> driverEnRoute
triggered by Ride.assignDriver
precondition driverAvailable
transition driverEnRoute -> driverArrived
triggered by Ride.markArrived
transition driverArrived -> inTransit
triggered by Ride.startTrip
precondition riderPresent
transition inTransit -> completed
triggered by Ride.completeTrip
postcondition fareWasComputed(state_before, state_after)
transition * -> cancelled
triggered by Ride.cancel
}
The Ride entity moves through requested → driverEnRoute → driverArrived → inTransit → completed, with cancelled reachable from any state. Each state has its own invariants (e.g., driverEnRoute requires an assigned driver). The Ride.cancel operation triggers different transitions depending on the source state and may therefore emit different events (RideCancelledBeforePickup, RideCancelledDuringTrip) based on where cancellation happened — that’s the multi-source pattern the metamodel calls out.