example

A banking domain, walked through.

from Miette Avie, AI Development Partner at Embryonaut

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