A complete banking domain in bluebook : Customer, Account, Transfer, Loan. Four aggregates, three embedded entities, twenty-three queries, five cross-aggregate policies. Every command emits a named event ; every state change is a then_set ; every precondition is a given clause. Read the walkthrough below and you have seen the discipline at work.
I. The Customer.
Two attributes — a name, an email — both wrapped in value objects that protect their own invariants. A lifecycle with from-guards : suspension is only valid from active, reinstatement only from suspended. The state machine cannot be reached out of order.
aggregate "Customer" do
identified_by :id
attribute :id, CustomerId
attribute :name, PersonName
attribute :email, EmailAddress
value_object "PersonName" do
attribute :given, String
attribute :family, String
invariant "given name present" do given && !given.strip.empty? end
invariant "family name present" do family && !family.strip.empty? end
end
value_object "EmailAddress" do
attribute :address, String
invariant "must contain @" do address.include?("@") end
invariant "must contain domain dot" do
address.split("@", 2).last.to_s.include?(".")
end
end
lifecycle :status, default: "active" do
transition "SuspendCustomer" => "suspended", from: "active"
transition "ReinstateCustomer" => "active", from: "suspended"
end
command "RegisterCustomer" do
attribute :id, CustomerId
attribute :name, PersonName
attribute :email, EmailAddress
emits "CustomerRegistered"
then_set :id, to: :id
then_set :name, to: :name
then_set :email, to: :email
end
Every command names its emits event and its then_set mutations. RegisterCustomer sets the id, name, email ; the runtime applies the mutations and the event hits the bus. There is no setter outside the command surface.
Queries are first-class :
query "ActiveCustomers" do
description "Customers whose status is active."
where(status: "active")
end
query "SuspendedCustomers" do
description "Customers whose status is suspended."
where(status: "suspended")
end
The query is a named view ; the runtime reads it like a stored procedure. No ORM, no Repository pattern over-engineered into existence ; the query is declared where it belongs.
II. Money never floats.
The Account aggregate brings in Money — a typed value object whose internal representation is integer cents. IEEE 754 rounding errors have no place in a ledger ; the bluebook makes that structural.
value_object "Money" do
attribute :cents, Integer
attribute :currency, Currency
invariant "non-negative for balance contexts" do
cents >= 0
end
end
value_object "Currency" do
attribute :code, String
invariant "ISO 4217 three-letter code" do
code.is_a?(String) && code.length == 3 && code == code.upcase
end
end
Money cannot exist without a currency. Currency cannot exist without a valid ISO 4217 code. A USD is fine. A usd is not. The invariant catches it at construction, not at the wire.
III. The Account, and its embedded ledger.
The Account references the Customer, holds a balance, an account type, a daily limit, and a list of LedgerEntry entities. The entity primitive is the bluebook's way of saying "this is owned-by-the-aggregate, not a sibling aggregate." Ledger entries are part of an account ; they cannot exist without one.
aggregate "Account" do
identified_by :id
attribute :id, AccountId
reference_to(Customer)
attribute :balance, Money
attribute :account_type, AccountType
attribute :daily_limit, Money
attribute :ledger, list_of(LedgerEntry)
entity "LedgerEntry" do
attribute :amount, Money
attribute :description, Description
attribute :entry_type, EntryType
attribute :posted_at, Timestamp
end
Deposit and Withdraw are where the real discipline shows. Every precondition is named ; every state change is named ; the audit trail falls out of the runtime by construction.
command "Deposit" do
reference_to(Account)
attribute :amount, Money
attribute :description, Description
given { amount.cents > 0 }
given { amount.currency.code == balance.currency.code }
emits "Deposited"
then_set :balance, increment: :amount
then_set :ledger, append: { amount: :amount,
description: :description,
entry_type: "credit" }
end
command "Withdraw" do
reference_to(Account)
attribute :amount, Money
attribute :description, Description
given { amount.cents > 0 }
given { amount.currency.code == balance.currency.code }
given { balance.cents >= amount.cents } # insufficient-funds gate
given { amount.cents <= daily_limit.cents } # daily-limit gate
emits "Withdrawn"
then_set :balance, decrement: :amount
then_set :ledger, append: { amount: :amount,
description: :description,
entry_type: "debit" }
end
Four given clauses on Withdraw : positive amount, matching currency, sufficient balance, within daily limit. Each one is a named precondition the runtime checks before the mutation runs. There is no procedural if-balance-too-low-then-return-error code to forget to write ; the gate is in the declaration.
And every Withdraw appends a LedgerEntry. The runtime composes the entry from the command's attributes — amount, description, type "debit" — and adds it to the embedded list. Reading the ledger reads the audit trail. There is no separate audit log to keep synchronised ; the bluebook makes the ledger structural.
IV. The Transfer, with two references and a step audit.
A Transfer references two Accounts — a source and a destination — disambiguated by as:. The amount is a Money with a "positive for transfers" invariant ; the memo is bounded at 280 characters. And the saga of the transfer is recorded as a list of TransferStep entities — initiated, debited, credited, completed (or rejected) — appended in event order.
aggregate "Transfer" do
identified_by :id
attribute :id, TransferId
reference_to(Account, as: :source)
reference_to(Account, as: :destination)
attribute :amount, Money
attribute :memo, Memo
attribute :steps, list_of(TransferStep)
value_object "StepKind" do
attribute :name, String
invariant "one of the saga step names" do
%w[initiated debited credited completed rejected].include?(name)
end
end
entity "TransferStep" do
attribute :kind, StepKind
attribute :at, Timestamp
attribute :amount, Money
attribute :reason, Description
end
lifecycle :status, default: "pending" do
transition "CompleteTransfer" => "completed", from: "pending"
transition "RejectTransfer" => "rejected", from: "pending"
end
command "InitiateTransfer" do
attribute :id, TransferId
reference_to(Account, as: :source)
reference_to(Account, as: :destination)
attribute :amount, Money
attribute :memo, Memo
attribute :initiated_at, Timestamp
given { source_id != destination_id } # self-transfer guard
given { amount.cents > 0 }
emits "TransferInitiated"
then_set :id, to: :id
then_set :source_id, to: :source_id
then_set :destination_id, to: :destination_id
then_set :amount, to: :amount
then_set :memo, to: :memo
then_set :steps, append: { kind: "initiated",
at: :initiated_at,
amount: :amount }
end
end
Three states in the lifecycle — pending, completed, rejected — with from-guards on every transition. The saga's audit trail lives in the steps list ; reading the list reads the order of events. List order IS event order, no separate timestamp sort needed.
V. The Loan, and the lesson in its reference graph.
The Loan aggregate makes a small but important design choice : it references only the Account, not the Customer. The customer is reached via the loan's account. One path through the reference graph means there is nothing to disagree with.
aggregate "Loan" do
# The Customer is reached via loan.account.customer_id.
# No redundant `reference_to Customer` ; one path through the
# graph means there is nothing to disagree with.
identified_by :id
attribute :id, LoanId
reference_to(Account)
attribute :principal, Money
attribute :rate, InterestRate
attribute :term, Term
attribute :remaining_balance, Money
attribute :payments, list_of(LoanPayment)
# Rate as basis points (525 = 5.25%) — Float APRs are a
# round-tripping nightmare in interest accrual.
value_object "InterestRate" do
attribute :basis_points, Integer
invariant "between 0 and 10000 bps" do
basis_points >= 0 && basis_points <= 10000
end
end
# Term as a typed value — "60 months" not a bare integer that
# could be days, weeks, or years depending on who reads it.
value_object "Term" do
attribute :months, Integer
invariant "positive duration" do months > 0 end
end
entity "LoanPayment" do
attribute :amount, Money
attribute :at, Timestamp
attribute :remaining_after_payment, Money
end
Every payment is a LoanPayment entity appended to the embedded list. The list IS the payment history — date, amount, post-payment balance. Servicing reports walk the list ; collections reads the gap between the last entry and "now" to detect delinquency.
command "IssueLoan" do
attribute :id, LoanId
reference_to(Account)
attribute :principal, Money
attribute :rate, InterestRate
attribute :term, Term
given { principal.cents > 0 }
emits "LoanIssued"
then_set :id, to: :id
then_set :account_id, to: :account_id
then_set :principal, to: :principal
then_set :rate, to: :rate
then_set :term, to: :term
then_set :remaining_balance, to: :principal
end
command "MakePayment" do
reference_to(Loan)
attribute :amount, Money
attribute :paid_at, Timestamp
given { amount.cents > 0 }
given { amount.cents <= remaining_balance.cents } # cannot overpay
given { amount.currency.code == remaining_balance.currency.code }
emits "LoanPaymentMade"
then_set :remaining_balance, decrement: :amount
then_set :payments, append: {
amount: :amount,
at: :paid_at,
remaining_after_payment: :remaining_balance
}
end
The given { amount.cents <= remaining_balance.cents } clause makes overpayment structurally impossible. The payment record carries the post-payment remaining_balance so the entire amortisation curve can be reconstructed from the list. The bluebook makes the audit by construction.
VI. The policies that tie the domain together.
The four aggregates above each stand alone. The policies below make them speak to each other through events. No aggregate imports another ; they communicate via the bus.
# Loan disbursement : when a loan is issued, deposit the
# principal into the loan's referenced account.
policy "DisburseFunds" do
on "LoanIssued"
trigger "Deposit"
map account_id: :account_id,
principal: :amount,
description: "Loan disbursement"
end
# Transfer saga, three policies, three event hops :
policy "DebitTransferSource" do
on "TransferInitiated"
trigger "Withdraw"
map account_id: :source_id,
amount: :amount,
description: "Transfer debit"
end
policy "CreditTransferDestination" do
on "TransferInitiated"
trigger "Deposit"
map account_id: :destination_id,
amount: :amount,
description: "Transfer credit"
end
policy "CompleteOnDeposited" do
on "Deposited"
trigger "CompleteTransfer"
map transfer_id: :transfer_id
end
DisburseFunds is the loan-issuance cascade : the Loan emits LoanIssued, the policy listens, dispatches Deposit on the loan's referenced Account with the principal as the deposit amount. The Account does not import the Loan. The Loan does not import the Account. The policy is the seam.
The Transfer saga is three policies, three event hops. InitiateTransfer fires TransferInitiated ; two policies listen and dispatch Withdraw on the source and Deposit on the destination. When the Deposited event arrives, a third policy fires CompleteTransfer. The Transfer aggregate transitions from pending to completed. Throughout, every step records a TransferStep entity ; the audit trail walks itself.
This is the four-rule reduction in practice : aggregates communicate through events. No shared mutable state. No circular import. The reference graph is the truth ; the event flow is what changes the state along it.
VII. What this domain runs as.
The bluebook above is around five hundred lines. When storehouse run reads them, the runtime constructs the four aggregates, every value object validates at construction, every command is registered as a dispatchable verb that runs its given clauses and applies its then_set mutations, every lifecycle becomes a real state machine with from-guards enforced, every policy attaches to the bus, every query becomes a named view, the embedded entities populate their lists in event order. You have a running banking domain. There is no separate code generator that produces a different runtime version ; no documentation that goes stale ; no audit log to keep synchronised. The bluebook is the program.
The full source lives at examples/banking/hecks/banking.bluebook in the repository. Five hundred and forty lines for what would be ten times that in any conventional stack.
See what we build for your business →
Read next : What we build for your business →
Four aggregates, three embedded entities, twenty-three queries, five cross-aggregate policies, no translation gap. The bluebook is the running program. Avec attention, toujours, Miette Avie