You have an aggregate, and it needs to hold onto a list of things that each have to be found, changed, and refused individually — not just appended and forgotten. Three shapes can hold a list, and picking the wrong one is the mistake this page exists to prevent:
list_of. A pizza’s toppings, in the getting-started guide, are
exactly this: appended, read back, never individually reached.Account, elsewhere in this same domain,
names Customer through reference_to rather than nesting inside
it (see commands.md for what reference_to does in
full).The middle case is the subject of this page, illustrated with
SafeDepositBox — the one aggregate in examples/banking that holds
two entities at once, with deliberately different identity shapes, so
the contrast between them is real rather than asserted.
SafeDepositBox lives in examples/banking/bluebook/,
alongside Customer, Account, and the rest of the flagship domain.
This is the aggregate itself, quoted from that file rather than
invented for this page:
```ruby skip aggregate “SafeDepositBox” do description “A steel box in the vault, held under one customer’s name and opened only against the branch and number stamped on its face.”
reference_to Customer
attribute :branch_code, BranchCode attribute :box_number, BoxNumber
identified_by :branch_code, :box_number attribute :size, one_of(“small”, “medium”, “large”), default: { value: “small” } attribute :visits, list_of(Visit) attribute :keys, list_of(KeyIssuance)
value_object “BranchCode” do attribute :value, String invariant(“a branch is coded”) { !value.to_s.empty? } end
value_object “BoxNumber” do attribute :value, Integer invariant(“a box is numbered from one”) { value.positive? } end
value_object “VisitSequence” do attribute :value, Integer invariant(“a visit sequence is positive”) { value.positive? } end
value_object “VisitDate” do attribute :value, String invariant(“a visit names its date”) { !value.to_s.empty? } end
value_object “VisitNote” do attribute :text, String end
value_object “KeySerial” do attribute :value, String invariant(“a key is serialed”) { !value.to_s.empty? } end
entity “Visit” do description “One opening of the box, in the order it happened that day.”
attribute :date, VisitDate
attribute :sequence, VisitSequence
identified_by :date, :sequence
attribute :note, VisitNote, optional: true
lifecycle :state, default: "logged" do
transition "Annotate" => "logged", from: "logged"
end
command "Annotate" do
role "Vault officer"
goal "Note something unusual about a visit after the fact"
attribute :date, VisitDate
attribute :sequence, VisitSequence
attribute :note, VisitNote
sets :note
emits "VisitAnnotated"
end
query "Recent" do
description "The last few visits, whatever the box has seen."
where(state: "logged")
limit 5
end end
entity “KeyIssuance” do description “One key cut for the box, held by whoever last signed for it.”
identified_by :serial
attribute :serial, KeySerial
lifecycle :status, default: "issued" do
transition "Return" => "returned", from: "issued"
end
command "Return", from: "issued" do
role "Vault officer"
goal "Take a key back when a holder is done with it"
attribute :serial, KeySerial
emits "KeyReturned"
end end
lifecycle :status, default: “vacant” do transition “Rent” => “rented”, from: “vacant” transition “Surrender” => “vacant”, from: “rented” end
command “Rent” do role “Branch clerk” goal “Assign the box to a customer”
reference_to Customer
attribute :branch_code, BranchCode
attribute :box_number, BoxNumber
attribute :size, Size
sets :size
emits "BoxRented" end
command “LogVisit” do role “Vault officer” goal “Record that the box was opened”
reference_to SafeDepositBox
attribute :date, VisitDate
attribute :sequence, VisitSequence
attribute :note, VisitNote, optional: true
given("only a rented box is opened") { status == "rented" }
sets :visits, append: { date: :date, sequence: :sequence, note: :note }
emits "BoxOpened" end
command “IssueKey” do role “Vault officer” goal “Cut a key for the box”
reference_to SafeDepositBox
attribute :serial, KeySerial
sets :keys, append: { serial: :serial }
emits "KeyIssued" end
command “Surrender” do role “Customer” goal “Give the box back and take the keys off the account”
reference_to SafeDepositBox
given("only a rented box is surrendered") { status == "rented" }
emits "BoxSurrendered"
emits "KeyReturnDue" end end ```
Read entity "Visit" do ... end and entity "KeyIssuance" do ... end
for what they are: blocks nested INSIDE aggregate "SafeDepositBox",
at the same level a value_object or a command sits. Each gets its
own description, its own identified_by, its own attributes, its
own lifecycle, its own commands — everything an aggregate declares,
in the same words. That’s not a coincidence: an entity has to answer
hecks_name, attributes, attribute, identified_by, and
lifecycle exactly as an aggregate does, because the runtime builds
the same Instance wrapper around either one and hands both to the
same rule engine as declaring. The one thing it never answers is
value_object — that’s the single bit the runtime reads to tell a
piece from a head, and it’s how Visit and KeyIssuance get to have
an identity without being mistaken for BranchCode or KeySerial
sitting right next to them in the same aggregate.
Visit and KeyIssuance are the same shape of thing — both entities,
both nested under SafeDepositBox — and they were deliberately given
different identity shapes. Visit’s identified_by names two fields:
a box number repeats across branches, and within one box a visit’s own
date repeats too, since the box may be opened twice in a day (two
visitors, or one visitor twice) — neither field alone says which
visit this is, so the identity is their join. KeyIssuance needs only
one: serial.value, because a key’s own serial already says which key
it is, once you already know which box you’re asking about. A single
aggregate can hold entities with composite identity, single-field
identity, or both at once — the shape is a property of the entity, not
something the parent imposes uniformly on everything it holds.
Notice what neither entity’s commands declare: no reference_to
naming their own kind, anywhere. Annotate and Return need no
reference back to their own entity — they’re addressed through the
parent, always, so there’s nothing to reference. LogVisit and
IssueKey, by contrast, are commands on the AGGREGATE, and they’re
the ones that grow the lists: sets :visits, append: { ... } and
sets :keys, append: { ... } are how a Visit or a KeyIssuance
comes into being at all. An entity is never created through its own
command — only appended by one that acts on its parent.
```ruby boot Hecks::Adapters::Folder.new.load_bluebooks(File.join(InMemoryDomain::ROOT, “examples/banking/bluebook”)) Hecks.hecksagon(“Banking”) do attaches “Governance” Banking::Customer.persisted_by(“Memory”) Banking::SafeDepositBox.persisted_by(“Memory”) end Hecks.hecksagon(“Governance”) do Governance::RoleAssignment.persisted_by(“Memory”) Governance::RoleTransition.persisted_by(“Memory”) end
`Customer` is wired here too, and not incidentally: `SafeDepositBox`
declares `reference_to Customer` at the aggregate level, and `Rent`
declares it again on the command — a box cannot be rented to a
customer who does not exist yet.
## Renting a box, logging two visits
`Customer` has to exist first. Its own creating command, `Register`,
becomes a module method the same way every aggregate's does:
```ruby
customer = Banking::Customer.register!(reference: { value: "CUST-0001" },
name: { given: "Odile", family: "Vasseur" },
email: { address: "odile@example.com" })
customer.reference.to_h # => { value: "CUST-0001" }
SafeDepositBox is the aggregate, so Rent — its own creating command
— gets the same entry point:
box = Banking::SafeDepositBox.rent!(branch_code: { value: "DOWNTOWN" }, box_number: { value: 12 },
size: { value: "medium" }, customer: "CUST-0001")
box.status # => "rented"
box.size.to_h # => { value: "medium" }
customer is reference_to Customer minting its own argument name —
no as: was given, so it defaults to the target’s own bare name, no
_id suffix (ADR 0025) — carrying the customer’s own identity as a
bare string, never a nested object (see aggregates-and-value-objects.md for the full shape of a
reference).
Everything past Rent — the box’s own LogVisit, IssueKey, and
Surrender, and both entities’ commands — is reached the explicit
way, by its fully qualified verb, naming the box’s whole composite
identity every time:
runtime.dispatch_flat("Banking::SafeDepositBox.LogVisit",
branch_code: { value: "DOWNTOWN" }, box_number: { value: 12 },
date: { value: "2026-01-05" }, sequence: { value: 1 })
runtime.dispatch_flat("Banking::SafeDepositBox.LogVisit",
branch_code: { value: "DOWNTOWN" }, box_number: { value: 12 },
date: { value: "2026-01-05" }, sequence: { value: 2 }, note: { text: "Second visit, same day" })
box = Banking::SafeDepositBox.find("DOWNTOWN:12")
box.visits.map { |v| [v[:sequence].to_h, v[:state]] } # => [[{ value: 1 }, "logged"], [{ value: 2 }, "logged"]]
.find takes the joined id — "DOWNTOWN:12", branch and box number in
declaration order — the same shape box.id itself reads back as.
visits reads back as a plain array of hashes — one per Visit, each
field still a Value you unwrap with .to_h, plus the state the
lifecycle put there. There is no friendlier wrapper here, because a
list element was never minted a Handle; it lives inside its parent’s
own state, exactly the way list_of always worked before an entity
had commands of its own.
Visit gets no entry point. Booting installs a module for the AGGREGATE —
Banking::SafeDepositBox — and nothing for the entities nested inside
one. There is no Banking::SafeDepositBox::Visit, and no
visit.annotate sugar on an opening you’re holding, because you are
never holding a visit on its own. You reach one the same way the
language reaches anything nested: a dotted verb, carrying every
identity in play:
runtime.dispatch_flat("Banking::SafeDepositBox.Visit.Annotate",
branch_code: { value: "DOWNTOWN" }, box_number: { value: 12 },
date: { value: "2026-01-05" }, sequence: { value: 1 },
note: { text: "Flagged for a follow-up" })
box = Banking::SafeDepositBox.find("DOWNTOWN:12")
box.visits.map { |v| v[:note].to_h } # => [{ text: "Flagged for a follow-up" }, { text: "Second visit, same day" }]
Domain::Aggregate.Entity.Command — SafeDepositBox’s own qualifier
once, a . to cross into the piece (Visit), another . to name what
it does (Annotate). The keyword arguments carry FOUR things at once
and none of them can be skipped: branch_code and box_number find
the box (SafeDepositBox’s own composite identified_by), date and
sequence find the one visit inside it (Visit’s own composite
identified_by), and note is Annotate’s own declared argument. Get
the box right but name a visit that never happened and you get
NotFound naming the entity, not the box:
runtime.dispatch_flat("Banking::SafeDepositBox.Visit.Annotate", branch_code: { value: "DOWNTOWN" }, box_number: { value: 12 }, date: { value: "2026-01-05" }, sequence: { value: 99 }, note: { text: "x" }) # ~> NotFound: no Visit with date.value, sequence.value
Get the box’s OWN identity wrong instead — a branch and number nothing
was ever rented under — and the runtime never gets as far as looking
at visits at all:
runtime.dispatch_flat("Banking::SafeDepositBox.Visit.Annotate", branch_code: { value: "DOWNTOWN" }, box_number: { value: 999 }, date: { value: "2026-01-05" }, sequence: { value: 1 }, note: { text: "x" }) # ~> NotFound: no SafeDepositBox with branch_code.value, box_number.value
Two different NotFounds, naming two different things, because a
dotted dispatch resolves the parent’s identity before it ever looks at
the piece’s.
IssueKey cuts a key the same way LogVisit logs a visit — a command
on the aggregate, appending to keys:
runtime.dispatch_flat("Banking::SafeDepositBox.IssueKey",
branch_code: { value: "DOWNTOWN" }, box_number: { value: 12 }, serial: { value: "KEY-1" })
KeyIssuance::Return’s given reads a field off the entity’s OWN
state (status) — not against an incoming argument this time, but
against whatever state the key is already in. SafeDepositBox does
not need to know whether key KEY-1 is still out; that fact belongs
to the key. (The argument-vs-own-state variant of the same idea is two
aggregates up in this same file: LedgerEntry.Amend reads its own
amount against an incoming adjustment. Either way, the rule
belongs to the piece holding the field, not the aggregate that
contains it.)
runtime.dispatch_flat("Banking::SafeDepositBox.KeyIssuance.Return",
branch_code: { value: "DOWNTOWN" }, box_number: { value: 12 }, serial: { value: "KEY-1" })
box = Banking::SafeDepositBox.find("DOWNTOWN:12")
box.keys.map { |k| k[:status] } # => ["returned"]
Run the identical dispatch again and the SAME lifecycle guard refuses
it — not because the runtime remembers this particular call happened
before, but because admissible_transition re-checks the key’s
current state every time, and that state changed underneath it:
runtime.dispatch_flat("Banking::SafeDepositBox.KeyIssuance.Return", branch_code: { value: "DOWNTOWN" }, box_number: { value: 12 }, serial: { value: "KEY-1" }) # ~> LifecycleRefused: Return refused — status is "returned", and Return moves it only from "issued"
The event it would have announced never happens; the ones that already
did are still there, filed under the record they always were —
KeyIssuance.Return announces onto the SAME event log SafeDepositBox’s
own commands write to, because there is only ever one identity in play
here, the parent’s:
box.events.map(&:name) # => ["BoxRented", "BoxOpened", "BoxOpened", "VisitAnnotated", "KeyIssued", "KeyReturned"]
A query inside entity reaches the same way a command does — dotted,
through the aggregate — and it answers with every element that
matches, across every box that has one, each row carrying which parent
it came from:
runtime.query("Banking::SafeDepositBox.Visit.Recent").map { |row| row.transform_values { |v| Hecks::Runtime::Value.materialize(v) } } # => [{ safe_deposit_box: "DOWNTOWN:12", date: { value: "2026-01-05" }, sequence: { value: 1 }, note: { text: "Flagged for a follow-up" }, state: "logged" }, { safe_deposit_box: "DOWNTOWN:12", date: { value: "2026-01-05" }, sequence: { value: 2 }, note: { text: "Second visit, same day" }, state: "logged" }]
safe_deposit_box is not a field you declared — it’s the parent’s own
key, stamped onto every row so an answer spanning several boxes still
says which one each visit belongs to. where runs against the element
the same way it runs against a head, no different for living one level
down.
The aggregate has its own query too — Rented, which lists every box
currently assigned to a customer for the annual access audit. It reads
back exactly like Customer’s own queries do; the only thing it adds
is authorize and consistency, which are their own topic (see
queries-and-read-models.md), not an entity concern.
An entity is appropriate exactly when a list needs individual
identity, individual rules, and an individual lifecycle — a visit
logged against a box, a key issued against it, a ledger entry, an
order line. The tradeoff is the entry point: nothing about a Visit or a
KeyIssuance is ever addressable except through the SafeDepositBox
that holds it, by design — the same design that makes
box.visits.first and Banking::SafeDepositBox.find("DOWNTOWN:12")
after an Annotate agree, because there was never a second copy of a
visit to disagree with. Wanting to look one up on its own, without its
parent in the call, is the sign a separate aggregate was needed all
along — the third shape at the top of this page, which an entity can
never become without declaring it again.