A command either fires or it refuses, and given is how you write the
refusal that depends on a fact you compute. A lifecycle is different: it
is how you say, once and for all, which STATES a command may fire from
— so the illegal call refuses on its own, and you never have to write
a given that just re-checks a field you already named the states of.
Before you ship a command that only makes sense at certain points in a
record’s life, decide those points here. Get it right and a caller who
calls things out of order gets told so, in words you wrote, before
anything is written to your store. Get it wrong — forget to give a
state an exit, or declare a transition that can never fire — and
hecks model_check is the tool that tells you before your users do.
The examples in this guide run against the real corpus,
examples/banking/bluebook/’s CardPayment aggregate:
a card authorisation that is captured, may be challenged after it
settles, and either comes back from that dispute or is charged back
for good. It is one aggregate out of a much larger domain, but
charging back a payment nobody ever disputed is exactly the bug a
lifecycle exists to make impossible.
CardPayment’s own lifecycle, exactly as it reads in the real file —
shown here, not redeclared, so nothing on this page can drift from
what the corpus actually says:
```ruby skip
lifecycle :status, default: “authorized” do transition “Capture” => “captured”, from: “authorized” transition “Void” => “voided”, from: “authorized” transition “Refund” => “refunded”, from: “captured” transition “Reverse” => “reversed”, from: “captured” transition “Dispute” => “disputed”, from: [“captured”, “refunded”] transition “Chargeback” => “charged_back”, from: “disputed” transition “RejectDispute” => “captured”, from: “disputed” end
That `lifecycle` block is the whole shape: a field (`:status`), a
`default:` it starts at, and one `transition` line per legal move —
`"Command" => "target", from: "source"`. Nothing else belongs to it.
`from:` may also be an array, when more than one state lets a command
fire — `Dispute` above fires from either `"captured"` or `"refunded"`,
the same command handling a challenge whether or not the charge was
ever refunded first.
## Wiring
```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::CardPayment.persisted_by("Memory")
end
Hecks.hecksagon("Governance") do
Governance::RoleAssignment.persisted_by("Memory")
Governance::RoleTransition.persisted_by("Memory")
end
A card payment authorizes against an existing account, so the walkthrough needs one first:
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "c1" },
name: { given: "Odile", family: "Payer" }, email: { address: "odile@example.com" } })
runtime.dispatch("Banking::Account.Open", with: { customer: "c1", number: { value: "a1" },
kind: { name: "current" }, daily_limit: { cents: 50_000 } })
A payment starts in whichever state is named as default: — no
transition sets it, because Authorize is the creating command and
"authorized" is simply where a payment is born:
payment = Banking::CardPayment.authorize!(account: "a1",
authorisation: { value: "auth-742" }, amount: { cents: 4200 },
merchant: { value: "Corner Shop" })
payment.status # => "authorized"
Capture it, and the field moves on its own. There is no sets
:status anywhere on Capture — the transition IS the assignment,
applied after the command’s other mutations, the step
command_interpreter.rb calls advance_lifecycle:
payment.capture!
payment.status # => "captured"
"captured" is not only where Capture lands a payment — it is also
where RejectDispute sends one back. Challenge the charge, then have
the dispute thrown out, and it returns to the same settled state by a
different transition:
payment.dispute!(disputed_by: "c1")
payment.status # => "disputed"
payment.reject_dispute!
payment.status # => "captured"
Two commands, one target state, and neither has to know the other
exists. That is the whole benefit of naming states instead of a
boolean per fact: "captured" means one thing, however a payment
arrived there.
Try to charge back a payment that was never disputed — it is sitting
there "captured", not "disputed" — and the command refuses before
anything is written:
payment.chargeback! # ~> LifecycleRefused: Chargeback refused — status is "captured", and Chargeback moves it only from "disputed"
Before S10 (ADR 0025), Chargeback also carried its own
given("payment is disputed") { status == "disputed" } — naming in
business language exactly the condition transition "Chargeback" =>
"charged_back", from: "disputed" already enforced structurally, one
idea spelled twice. The given is gone now; admissible_transition
is the only thing that checks this, and LifecycleRefused is what
every caller sees. This is the whole point of declaring the states a
command may fire from at the lifecycle, once: the check exists whether
or not any command also restates it in a given, so there is nothing
left to restate.
Dispute the payment again, and this time carry the charge back for real — it lands in a state this lifecycle defines no exit from:
payment.dispute!(disputed_by: "c1")
payment.chargeback!
payment.status # => "charged_back"
payment.reject_dispute! # ~> LifecycleRefused: RejectDispute refused — status is "charged_back", and RejectDispute moves it only from "disputed"
Same shape as Chargeback above — RejectDispute’s own guard is
admissible_transition alone now too. No transition names from:
"charged_back" either way — on purpose, a charged-back payment is
done. hecks model_check notices this
shape on every
lifecycle in the corpus and reports it as a stuck_state finding —
but at :warning severity, not :error, because a genuinely terminal
state ("sold" in the pizzas example, "closed" on a bank account) is
completely fine. The finding exists so you notice and confirm it is
what you meant, not so the checker blocks you for it. Ask the checker
yourself, the same way spec/model_check_spec.rb does — boot a
registry, hand the bluebook to ModelCheck.call:
require "hecks/bluebook/model_check"
findings = Hecks::Bluebook::ModelCheck.call(runtime.registry.bluebook("Banking"))
cp_findings = findings.select { |f| f.subject == "CardPayment" }
cp_findings.map { |f| [f.kind, f.severity] } # => [[:stuck_state, :warning], [:stuck_state, :warning], [:stuck_state, :warning]]
cp_findings.any? { |f| f.message.include?("voided") } # => true
cp_findings.any? { |f| f.message.include?("reversed") } # => true
cp_findings.any? { |f| f.message.include?("charged_back") } # => true
Three findings this time, not one — CardPayment has three states
this lifecycle never declares an exit from: "voided" and
"reversed", reached by paths this walkthrough never took, and
"charged_back", just shown live above. The checker names all three
anyway, because it reads the declaration, not a run — hecks model_check
would find "voided" and "reversed" stuck the same way even if this
page never dispatched a single command. Nothing here is an error — this
lifecycle is clean to ship, with three states its own domain considers
done.
A stuck state is a judgment call. A dead transition and an
unreachable state are not — they are declarations that can never
mean anything at runtime, and hecks model_check reports both as
:error. Nothing in examples/banking has either — the corpus stays
free of them on purpose, so the shape that produces them cannot come
from it. What follows is not a domain: it is a small, standalone
fragment, invented for exactly one purpose — giving the checker
something genuinely broken to catch, the same footing
spec/fixtures/model_check/lifecycle_findings.bluebook already stands
on in the test suite. It names a state, "flagged", that nothing ever
actually flags a lot INTO:
```ruby bluebook Hecks.bluebook “Impasse” do vision “A lot logged at the dock, inspected, and either cleared or discarded — except nothing ever flags one. A fragment, not a domain: the only place in this guide that is not banking.” supporting
aggregate “InboundLot” do description “One inbound lot, from logging to inspection.”
identified_by :lot
attribute :lot, LotNumber
value_object "LotNumber" do
attribute :value, String
invariant("a lot is numbered") { !value.to_s.empty? }
end
# "flagged" is only ever a `from:` — nothing transitions a lot INTO
# it, so Discard can never fire, and "purged" — Discard's only
# target — can never be reached either. One bad line, two findings.
lifecycle :status, default: "logged" do
transition "Inspect" => "cleared", from: "logged"
transition "Discard" => "purged", from: "flagged"
end
command "Log" do
role "Dock clerk"
goal "Register a lot as it arrives"
attribute :lot, LotNumber
emits "Logged"
end
command "Inspect" do
role "Quality inspector"
goal "Clear a logged lot for stowing"
reference_to InboundLot
emits "Inspected"
end
command "Discard" do
role "Quality inspector"
goal "Throw out a lot that failed inspection"
reference_to InboundLot
emits "Discarded"
end end end ```
findings = Hecks::Bluebook::ModelCheck.call(runtime.registry.bluebook("Impasse"))
findings.map { |f| [f.kind, f.severity] }.uniq.sort_by { |kind, _| kind.to_s }
# => [[:dead_transition, :error], [:stuck_state, :warning], [:unreachable_state, :error]]
findings.find { |f| f.kind == :dead_transition }.message.include?("can never fire")
# => true
findings.find { |f| f.kind == :unreachable_state }.message.include?("no path from")
# => true
Three kinds fire on this one lifecycle, and the severities say which
ones you get to judge and which ones you cannot ship past: Discard
naming a from: no path ever reaches (dead_transition), "purged"
consequently unreachable by anything (unreachable_state) — both
errors, both real declarations that can never do anything — and
"cleared" genuinely has no exit either, which is only a stuck_state
warning, because maybe that really is where an inspection flow ends
and maybe you forgot a Stow transition out of it. The checker cannot
tell your intent apart from your typo; it can only tell you dead code
apart from a live one. Run hecks model_check before you ship a
lifecycle, not after a caller reports that a command they expected to
work simply never fires. Impasse stops here — the rest of this guide,
and every other page that runs against real data, is banking.
Everything shown here about an aggregate’s lifecycle applies
unchanged to an entity nested inside one — entity_builder.rb
declares the same lifecycle keyword, and model_check walks an
entity’s states exactly as it walks its owning aggregate’s. Banking’s
own SafeDepositBox::KeyIssuance carries one ("issued" to
"returned", on the key itself, not the box that issued it). See the
entities guide for the rest of what an entity carries.