This guide covers the decisions involved in modeling domain state: whether a concept gets an identity of its own or is just a value two records might share, and whether an invalid shape is refused at the point a caller sends it or only discovered later against production data. Each section below covers one such decision, along with a demonstration of what happens when it is made incorrectly.
The domain used throughout is the real one this project ships:
examples/banking/bluebook/, a bank’s customers,
accounts, cards, and transfers. It is large enough to carry a composite
key, several closed vocabularies, a reference that is deliberately
misused to demonstrate a refusal, and the belongs_to sugar — every
construct this guide needs except one: banking’s own value objects are
all exactly one field deep, so the final section — a value object
holding another value object — draws instead from
examples/pizzas/bluebook/pizzas.bluebook’s Order, and says so where
it happens.
```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::Account.persisted_by(“Memory”) Banking::SafeDepositBox.persisted_by(“Memory”) Banking::CardPayment.persisted_by(“Memory”) Banking::ATMCard.persisted_by(“Memory”) Banking::ExternalTransfer.persisted_by(“Memory”) Banking::OnboardingCase.persisted_by(“Memory”) end Hecks.hecksagon(“Governance”) do Governance::RoleAssignment.persisted_by(“Memory”) Governance::RoleTransition.persisted_by(“Memory”) end
## Identity: one field, or several — and why that is not a style choice
The first decision, before any attribute, is whether a concept has a
lifecycle of its own, addressable by a key that never changes meaning —
an aggregate — or is just a value, interchangeable with any other
instance carrying the same fields — a value object. Getting this wrong
means either building CRUD around something that was never more than a
number, or letting two genuinely different records collide because
nothing told the runtime how to tell them apart.
`identified_by` is where an aggregate declares which field answers
"which one is this." A single path reads back exactly as written —
`Customer` is identified by its reference:
```ruby skip
# examples/banking/bluebook/
aggregate "Customer" do
identified_by :reference
attribute :reference, CustomerNumber
...
end
customer = Banking::Customer.register!(reference: { value: "CUST-1000" },
name: { given: "Ada", family: "Byron" },
email: { address: "ada@example.com" })
customer.id # => "CUST-1000"
A composite identity reads back as its parts joined by :, in the
order declared — SafeDepositBox needs one because a box number by
itself does not say which vault it is in:
```ruby skip
aggregate “SafeDepositBox” do attribute :branch_code, BranchCode attribute :box_number, BoxNumber
identified_by :branch_code, :box_number … end
```ruby
box = Banking::SafeDepositBox.rent!(customer: customer.id,
branch_code: { value: "downtown" },
box_number: { value: 12 },
size: { value: "small" })
box.id # => "downtown:12"
This is the actual purpose of identified_by: two boxes with the same
branch and number are not two boxes that happen to agree — they are the
same record, and a second Rent against that pair is not a fresh box,
it is a duplicate:
Banking::SafeDepositBox.rent!(customer: customer.id, branch_code: { value: "downtown" }, box_number: { value: 12 }, size: { value: "medium" }) # ~> AlreadyExists: already exists
That refusal is the whole reason identified_by exists. If two records
can carry the same fields and still be two different things (a customer
and their twin), you need a field that actually distinguishes them, or
your aggregate silently collapses cases the business considers
distinct. If two records with the same fields ARE the same thing (a
box, known by where it is), identified_by is what lets the runtime
catch a caller who tries to open it twice. This is not a modelling
nicety — it is the difference between a domain that notices a duplicate
and one that quietly overwrites a record because two callers happened
to agree on a name.
Every attribute line is a promise about what a field can hold and
what happens when a caller gets it wrong. The first decision is scalar
or value object: a bare String or Integer carries no rules of its
own, so any invariant, pattern, or closed set has to live wherever the
attribute is declared — and if you forget it on one command, that
command is the hole. A value object carries its rules once, wherever it
is used. If a field means something on its own (an amount that can’t go
negative, an address that must look a certain way), give it a value
object; if it is really just a scalar with a name, a String is honest
about that. Guess wrong here and you either write the same validation
four times (and eventually forget one), or you wrap a plain string in
ceremony nothing ever checks.
optional: and default: are two separate promises, easy to conflate.
optional: only says a caller may leave an argument out — it says
nothing about what the field becomes; default: is the separate,
explicit promise that fills a gap. Account::DailyLimit declares one
on its own field:
```ruby skip
value_object “DailyLimit” do attribute :cents, Integer, default: 0 invariant(“a daily limit is non-negative”) { !cents.negative? } end
Open an account and hand `daily_limit` an empty hash — the field itself
is required, but nothing says `cents` has to be in it, and `default:`
fills that gap the moment the record is built:
```ruby
account = Banking::Account.open!(customer: customer.id, number: { value: "ACC-1000" },
kind: { name: "current" }, daily_limit: {})
account.daily_limit.to_h # => { cents: 0 }
Now the other half of the promise. CardPayment.Authorize marks its
tags optional:
```ruby skip
command “Authorize” do reference_to Account attribute :authorisation, AuthorisationCode attribute :amount, PaymentAmount attribute :merchant, MerchantName attribute :tags, list_of(Tag), optional: true … end
Authorize a payment without ever mentioning `tags` — the command
accepts the call, no refusal for one missing:
```ruby
payment = Banking::CardPayment.authorize!(account: account.id,
authorisation: { value: "AUTH-1000" },
amount: { cents: 500 }, merchant: { value: "Cafe" })
payment.tags # => nil
Worth sitting with: tags did not become []. optional: only
promises the argument gate won’t refuse a missing key — it never
promises a fallback value. Authorize reads the missing key straight
through sets :tags, and an absent key resolves to
nil the same way any other unread hash key would — DailyLimit,
above, only got its fallback because it declared one explicitly.
default: can also sit on an attribute typed as a whole value object,
not just on one of that value object’s own fields — SafeDepositBox’s
size is declared the inline-shorthand way, and its default fills
Size’s own field, not the bare string a first instinct might reach
for:
```ruby skip
attribute :size, one_of(“small”, “medium”, “large”), default: { value: “small” }
```ruby
box.size.to_h # => { value: "small" }
Rent requires size on every call, so that default never actually
fires through ordinary traffic here — but the SHAPE is what the checker
validates the moment the bluebook loads, whether or not any command
path ever exercises it. Writing a bare scalar instead of a fields-hash
still lets the bluebook load — it fails later, at every single create,
because the value object wants its fields and gets a number instead:
def banking_bad_default
Hecks.with_registry(Hecks::Runtime::Registry.new) do
Kernel.load(InMemoryDomain::EXTRACTION_PORT)
Kernel.load(InMemoryDomain::PRISM_ADAPTER)
code = <<~RUBY
Hecks.bluebook("BankingBadDefault") do
aggregate "Thing" do
identified_by :thing_id
value_object("Price") { attribute :cents, Integer }
attribute :price, Price, default: 0
end
end
RUBY
file = Tempfile.new(["banking-bad-default-", ".bluebook"])
file.write(code)
file.flush
Kernel.eval(code, TOPLEVEL_BINDING, file.path, 1)
end
end
banking_bad_default # ~> Malformed: a default fills its FIELDS
That refusal fires the moment the bluebook is declared, not the moment
someone forgets to pass price. A default that cannot describe the
type it defaults is a bug you would otherwise ship and discover only
when every create using it fails.
A pattern is refused the same way — at declaration, not at the first
bad value. pattern: only admits regexes every engine reads
identically (explicit ranges, alternation, anchors); lookahead and the
\d/\w perl classes are refused outright, because they mean
different things — ASCII here, Unicode there — depending on which
engine reads them:
def banking_bad_pattern
Hecks.with_registry(Hecks::Runtime::Registry.new) do
Kernel.load(InMemoryDomain::EXTRACTION_PORT)
Kernel.load(InMemoryDomain::PRISM_ADAPTER)
code = <<~RUBY
Hecks.bluebook("BankingBadPattern") do
aggregate "Thing" do
identified_by :thing_id
value_object("Code") { attribute :value, String, pattern: '^(?=.*[A-Z]).+$' }
attribute :code, Code
end
end
RUBY
file = Tempfile.new(["banking-bad-pattern-", ".bluebook"])
file.write(code)
file.flush
Kernel.eval(code, TOPLEVEL_BINDING, file.path, 1)
end
end
banking_bad_pattern # ~> Malformed: uses a lookahead
A pattern that IS admitted still refuses a value that does not match
it — that check runs at the entry point, when a caller actually offers an
address, not buried inside a predicate three commands later.
Customer::EmailAddress declares one:
```ruby skip
value_object “EmailAddress” do attribute :address, String, pattern: ‘^[^@ ]+@[^@ ]+.[^@ ]+$’ end
```ruby
Banking::Customer.register!(reference: { value: "CUST-BAD" }, name: { given: "X", family: "Y" }, email: { address: "not-an-email" }) # ~> TypeMismatch: must match
And admits: refuses the same way, for a set declared somewhere else
entirely. ExternalTransfer’s direction names Account::LedgerDirection
rather than restating credit/debit a second time — once on the
head, once again on the Request command that fills it — so a caller
who ships a direction the ledger doesn’t recognise is refused with the
SAME vocabulary Account itself enforces:
```ruby skip
attribute :direction, MovementDirection, admits: “Account::LedgerDirection”
attribute :direction, MovementDirection, admits: “Account::LedgerDirection”
```ruby
ext = Banking::ExternalTransfer.request!(account: account.id, end_to_end: { value: "E2E-1000" },
amount: { cents: 1000 }, beneficiary: { value: "Someone" },
direction: { value: "debit" })
ext.direction.to_h # => { value: "debit" }
Banking::ExternalTransfer.request!(account: account.id, end_to_end: { value: "E2E-1001" }, amount: { cents: 1000 }, beneficiary: { value: "Someone" }, direction: { value: "sideways" }) # ~> InvariantViolation: got "sideways"
A value object’s invariant is not a validation attached to one
command — it travels with the type, into every command that carries
it. That is what invariant provides: the rule is written once, on the
value, and every future command that accepts that value inherits it
whether the author remembers to add it or not. The alternative — a
given repeated on each command that touches an amount — can hold for
the first several commands and silently stop holding on a later one
added without noticing the pattern.
PositiveMoney declared its invariant once — cents.positive? — and
it fires on two commands that have nothing else in common: Credit
puts money in, Debit takes it out, and both merely accept a
PositiveMoney:
```ruby skip
value_object “PositiveMoney” do attribute :cents, Integer attribute :currency, String, default: “USD” invariant(“an amount is positive”) { cents.positive? } … end
```ruby
funded = Banking::Account.open!(customer: customer.id, number: { value: "ACC-2000" },
kind: { name: "current" }, daily_limit: { cents: 5000 })
funded.credit!(amount: { cents: 0 }, narrative: { text: "bad" }) # ~> InvariantViolation: an amount is positive
funded = funded.credit!(amount: { cents: 1000 }, narrative: { text: "deposit" })
funded.balance.to_h # => { cents: 1000, currency: "USD" }
funded.debit!(amount: { cents: -5 }, narrative: { text: "bad" }) # ~> InvariantViolation: an amount is positive
funded = funded.debit!(amount: { cents: 400 }, narrative: { text: "withdrawal" })
funded.balance.to_h # => { cents: 600, currency: "USD" }
Neither command wrote that rule, and neither command could get it
wrong — the rule lives on the value, not on its callers. Nor is Credit
and Debit the whole story: ApplyFee, AccrueInterest,
CorrectFee, and CorrectInterest all accept PositiveMoney too, and
none of them had to restate a thing — every one of them was written
after Credit and Debit already existed, and every one of them
inherited the rule for free.
one_of ships a fixed vocabulary — a field that can only ever be one
of the values named, refused at the entry point for anything else. Three
spellings, picked by what the set is FOR: named and reusable, single
field or many; or anonymous and local to one attribute.
A NAMED, single-field set is the field’s own one_of: keyword, sitting
beside pattern:/admits: where value constraints already live —
Account::AccountKind is one:
```ruby skip
value_object “AccountKind” do attribute :name, String, one_of: [“current”, “savings”, “reserve”] end
```ruby
Banking::Account.open!(customer: customer.id, number: { value: "ACC-BAD" }, kind: { name: "gold" }, daily_limit: { cents: 0 }) # ~> InvariantViolation: got "gold"
A NAMED, MULTI-field set — each member carrying more than one
attribute — writes bare member lines instead, no one_of: (that
keyword only ever names one field, by construction):
```ruby skip
value_object “Comparison” do attribute :symbol, String attribute :compares_less_than, String attribute :compares_equal, String attribute :negated, String
member symbol: “>=”, compares_less_than: “true”, compares_equal: “false”, negated: “true” member symbol: “<=”, compares_less_than: “true”, compares_equal: “true”, negated: “false” … end
The inline shorthand skips naming a value object at all — `SafeDepositBox`'s
`size`, seen already above, synthesises one for you:
```ruby
Banking::SafeDepositBox.rent!(customer: customer.id, branch_code: { value: "uptown" }, box_number: { value: 99 }, size: { value: "huge" }) # ~> InvariantViolation: got "huge"
Reach for this one when the set is small, local, and not worth a name
anyone else will ever reuse — including, now, right inside a
value_object block itself. That used to crash with the wrong-arity
one_of a nested value object’s own closed-set builder defined; that
collision is gone along with the one_of do ... end wrapper it came
from, so the shorthand nests cleanly, synthesising its own anonymous
value object onto the SAME aggregate the nesting one belongs to (a
value object holds no value_objects of its own to nest inside):
def banking_nested_one_of
Hecks.with_registry(Hecks::Runtime::Registry.new) do
Kernel.load(InMemoryDomain::EXTRACTION_PORT)
Kernel.load(InMemoryDomain::PRISM_ADAPTER)
code = <<~RUBY
Hecks.bluebook("BankingNestedOneOf") do
aggregate "Thing" do
identified_by :thing_id
attribute :box, Box
value_object "Box" do
attribute :size, one_of("small", "large")
end
end
end
RUBY
file = Tempfile.new(["banking-nested-one-of-", ".bluebook"])
file.write(code)
file.flush
Kernel.eval(code, TOPLEVEL_BINDING, file.path, 1)
end
end
thing = banking_nested_one_of.aggregate("Thing")
thing.value_objects.map(&:hecks_name) # => ["Box", "Size"]
thing.value_object("Size").members # => [{ value: "small" }, { value: "large" }]
list_of declares a repeating field — not set once, but appended to by
whichever commands say so. A fresh card starts with no withdrawals at
all:
```ruby skip
attribute :withdrawals, list_of(Withdrawal) … value_object “WithdrawalAmount” do attribute :cents, Integer invariant(“a withdrawal amount is positive”) { cents.positive? } end
```ruby
card = Banking::ATMCard.issue!(account: account.id, serial: { value: "CARD-1000" },
daily_fee: { amount: 0.0 })
card.withdrawals # => []
A fresh card cannot pay out yet — Withdraw guards on status ==
"active", and a card just issued sits in "issued" until its holder
activates it:
card = card.activate!
card.status # => "active"
WithdrawalAmount carries its own invariant, and list_of does not
exempt an appended element from it. Every element built by append:
goes through the same construction a bare value would, invariant
included:
card = card.withdraw!(cents: { cents: 4000 }, narrative: { text: "ATM run" })
card.withdrawals.size # => 1
card.withdrawals.first[:cents].to_h # => { cents: 4000 }
card.withdraw!(cents: { cents: -100 }, narrative: { text: "bad" }) # ~> InvariantViolation: a withdrawal amount is positive
The rule you declared once on WithdrawalAmount holds for withdrawal
one and for withdrawal four hundred — a list_of field is not a place
invariants quietly stop applying.
Most of banking’s value objects are exactly one field deep —
AccountNumber{value}, DailyLimit{cents}, Narrative{text}. That
shape is common enough that the language treats it as its own rule:
a value object with exactly one declared attribute is a name for a
scalar, and always answers .value — whatever its real field is
called. The alias reads the sole field; the real name keeps working
beside it:
account.number.value # => "ACC-1000"
account.daily_limit.value # => 0
account.daily_limit.cents # => 0
card.withdrawals.first[:cents].value # => 4000
A multi-field value object refuses .value exactly as it always has —
PositiveMoney carries cents AND currency, so there is no single
value it could honestly mean:
account.balance.respond_to?(:value) # => false
The same rule works in the other direction, at the call site: wherever
an argument is typed as a single-attribute value object, a bare scalar
wraps into the sole field automatically. Withdraw takes a
WithdrawalAmount{cents} and a Narrative{text} — both collapse:
card = card.withdraw!(cents: 2_000, narrative: "coffee run")
card.withdrawals.size # => 2
card.withdrawals.last[:cents].to_h # => { cents: 2000 }
The explicit spelling (cents: { cents: 2_000 }) always keeps working
— collapsing only ADDS the bare form, and only where the target has
exactly one field; a multi-field value object still requires its
fields spelled out. Serialization is deliberately NOT aliased: to_h
above still says cents, so nothing stored, exported, or compared
changes shape.
Declaring that shape has a shorthand of its own — a type in second
position, no block — sugar for the block form’s single
attribute :value, Type line:
```ruby skip value_object “StickerRef”, String
value_object “StickerRef” do attribute :value, String end
Banking's own one-field value objects all keep the block form because
each carries a `pattern:` or an `invariant` too — the shorthand is for
the plain case, and refuses a block beside the type outright (two
answers to "what are the fields"). See the `value_object` reference
(aggregate.md) and the ValueObject context page's own
"single-attribute rule" section for the full contract.
## Pointing at another aggregate
A reference is how one aggregate names another, and getting the shape
wrong here does not fail loudly at declaration — it fails silently
later, when code reads a field expecting an id and gets something it
cannot use instead.
`reference_to Target` is the base form — it mints an attribute named
`target_id` by default, or whatever you pass as `as:`. `ExternalTransfer`
uses the base form for its account:
```ruby skip
# examples/banking/bluebook/
aggregate "ExternalTransfer" do
identified_by :end_to_end
reference_to Account
...
end
ext[:account] # => "ACC-1000"
That is a bare id — a String — not a nested object. A reference IS an id, so an id is the only shape it is stored as; hand it an object instead (the shape you would reach for reflexively, wrapping “the account” the way you would wrap any other field) and the runtime refuses it at the entry point, by name, rather than let a wrapped reference travel quietly into storage:
Banking::ExternalTransfer.request!(account: { value: account.id }, end_to_end: { value: "E2E-1002" }, amount: { cents: 1000 }, beneficiary: { value: "Someone" }, direction: { value: "debit" }) # ~> TypeMismatch: arrived as an object
as: renames what the base form would otherwise mint. CardPayment’s
Dispute carries a cross-reference to Customer named disputed_by,
not customer_id:
```ruby skip
command “Dispute” do reference_to CardPayment reference_to Customer, as: :disputed_by … end
```ruby
disputed = payment.capture!.dispute!(disputed_by: customer.id)
disputed[:disputed_by] # => "CUST-1000"
has_one and its alias belongs_to were sugar over the same
reference_to — the only difference was the attribute name: no _id
suffix, because the field already read as a relationship. They are
GONE now (ADR 0025, “References”) — reference_to mints that same
bare name on its own, so the sugar had no work left. OnboardingCase
was banking’s only user, and reaches for reference_to directly now:
```ruby skip
aggregate “OnboardingCase” do identified_by :reference reference_to Customer … end
```ruby
onboarding = Banking::OnboardingCase.open!(customer: customer.id, reference: { value: "ONB-1000" },
account_number: { value: "ACC-3000" })
onboarding[:customer] # => "CUST-1000"
Not customer_id — customer. And it is bound by the same rule as
every other reference: an id, never an object, refused at the entry point the
same way ExternalTransfer’s was above:
Banking::OnboardingCase.open!(customer: { value: customer.id }, reference: { value: "ONB-1001" }, account_number: { value: "ACC-3001" }) # ~> TypeMismatch: arrived as an object
One direction only: if Customer were to declare reference_to Account
back — on top of Account’s own reference_to Customer — the bluebook
would refuse to build. Two aggregates pointing at each other is not a
modelling choice this language leaves open, since neither side would be
a boundary a caller could reason about alone. spec/dsl_spec.rb’s
“refuses two aggregates that reference each other” is where that
refusal is proven; not reproduced here, since demonstrating it live
would mean breaking this very domain to show it.
One case to note in particular, absent from banking entirely — no
aggregate here ever reaches for has_many, so this is a small ad hoc
domain rather than the real corpus. has_many used to READ like it
should produce a list — spelled with the plural of the target — but
singularized the target back down and minted one scalar under the
plural name, never actually a list. S17/ADR 0026’s relationship-
cardinality slice fixed that lie rather than leaving it retired:
has_many genuinely mints a LIST now (relationship_attribute,
list: true), the one shape reference_to cannot express — admitting
zero members, never refused for being empty the way a required
singular relationship is:
def banking_has_many_demo
Hecks.with_registry(Hecks::Runtime::Registry.new) do
Kernel.load(InMemoryDomain::PERSISTENCE_PORT)
Kernel.load(InMemoryDomain::EXTRACTION_PORT)
Kernel.load(InMemoryDomain::MEMORY_ADAPTER)
Kernel.load(InMemoryDomain::PRISM_ADAPTER)
code = <<~RUBY
Hecks.bluebook("HasManyDemo") do
aggregate "Slip" do
identified_by :code
value_object("SlipTag") { attribute :value, String }
attribute :code, SlipTag
command("Open") { attribute :code, SlipTag; emits "SlipOpened" }
end
aggregate "Vault" do
identified_by :tag
value_object("VaultTag") { attribute :value, String }
attribute :tag, VaultTag
has_many Slip, as: :slips
command("Install") do
attribute :tag, VaultTag
attribute :slips, list_of(Slip), optional: true
sets :tag
sets :slips
emits "VaultInstalled"
end
end
end
RUBY
file = Tempfile.new(["has-many-demo-", ".bluebook"])
file.write(code)
file.flush
Kernel.eval(code, TOPLEVEL_BINDING, file.path, 1)
Hecks.hecksagon("HasManyDemo") do
HasManyDemo::Slip.persisted_by("Memory")
HasManyDemo::Vault.persisted_by("Memory")
end
Hecks::Runtime::Loader.bind_runtime(Hecks::Runtime::Dispatcher.new(Hecks.current_registry))
end
end
Genuinely a list — admits zero, never refused for it, and reads back exactly the ids it was given:
runtime = banking_has_many_demo
HasManyDemo::Slip.open!(code: { value: "s-1" })
HasManyDemo::Slip.open!(code: { value: "s-2" })
vault = HasManyDemo::Vault.install!(tag: { value: "v-1" }, slips: ["s-1", "s-2"])
vault[:slips] # => ["s-1", "s-2"]
Every value object in banking is exactly one field deep — nothing here nests one value object inside another. For that shape, this one example draws from pizzas instead, since it is real and banking’s own value objects simply never need it:
```ruby skip
value_object “Pizza” do attribute :price_cents, Price attribute :size, Size end
```ruby boot
Kernel.load(File.join(InMemoryDomain::ROOT, "examples/pizzas/bluebook/pizzas.bluebook"))
Hecks.hecksagon("Pizzas") do
attaches "Governance"
Pizzas::Order.persisted_by("Memory")
end
Hecks.hecksagon("Governance") do
Governance::RoleAssignment.persisted_by("Memory")
Governance::RoleTransition.persisted_by("Memory")
end
order = Pizzas::Order.create_pizza!(name: { value: "Margherita" },
pizza: { price_cents: { cents: 1200 }, size: { value: "large" } })
order.pizza.to_h # => { price_cents: { cents: 1200 }, size: { value: "large" } }
Nesting is ordinary: an attribute can be typed as another value object
just as easily as a scalar, and the nested shape travels with it, all
the way into storage and back out — price_cents is a Price, not a
bare integer, and size is a Size, not a bare string, and both
arrive and leave as the objects they were declared to be.
Reaching a nested scalar from a query — pizza.price_cents.cents, in
the language’s own corpus — is its own small topic, with its own rules
about where a dotted path is allowed to land; see
queries and read models for the full
treatment.