Modeling tutorial

A complete model, built step by step: a library with two Bounded Contexts, choreographed by policies and secured by scenarios.

This tutorial builds the model that ships with the studio as Open Example: a library. Books are acquired, members borrow and return them, a catalog shows what's available. The domain is deliberately simple — but it contains the pattern that event-sourced systems hinge on: a scarce resource, a gate, and bookkeeping that follows by choreography.

The example follows the ESDM format (Event-Sourced Domain Modeling, esdm.io). If you come from Event Modeling, you'll recognize the shape: hotel-room booking is structurally the same model — rooms are book copies, bookings are loans.

What you'll take away:

  • why an Aggregate is a consistency unit, not "a thing with fields",
  • the guiding principle for where a command belongs,
  • how two Bounded Contexts are choreographed by policies — in both directions, each for its own reason,
  • and how Given–When–Then scenarios make the model testable, including rejection and idempotency cases.

⚠️ This is a teaching model, not a real library system. A real library would model each physical copy with its own identity (a barcode), because copies aren't interchangeable in practice: they have a location, a condition, and their own lifecycle — and returns, damage reports, and loss processes all refer to that copy, not to the title. This example deliberately treats copies as fungible and tracks them as a counter on the title, because that keeps the artifact inventory minimal and puts the spotlight on what the tutorial actually teaches: invariants as gates, consistency units, and choreography via policies. A per-copy model would be the more realistic cut — and would pull in exactly the cross-instance questions (which free copy gets assigned?) that the last section touches on.

The domain and its cut

A library has two separate concerns, and they're separate in language too:

  1. Inventory: Which titles do we carry? How many copies? How many are on the shelf right now? Here people talk about titles, copies, acquisition, availability.
  2. Lending: Who has what, until when? Here people talk about loans, due dates, returns.

Background — Bounded Contexts: A Bounded Context (BC) is the boundary within which a term has exactly one meaning. In the inventory context, a "book" is a title with n copies; in the lending context, a book is just a reference on a loan slip. Where the language tips, the context boundary runs.

That gives us two Bounded Contexts in the domain library:

  • cataloging — the inventory, and (as we'll see) the circulation gate
  • lending — the bookkeeping of loans

The guiding principle

Before creating a single artifact, here is the one sentence everything in this example aligns to:

Every action starts at the aggregate whose invariant guards it. The other side follows via policy.

An aggregate in event sourcing is a consistency unit: the area within which a rule can be checked immediately and reliably, because all the facts it needs live in a single event stream. An invariant like "no copy may be checked out that isn't there" can only be enforced by the aggregate that knows the inventory. Send the triggering command anywhere else and the rule gets checked one step too late — after the fact.

Bounded Context cataloging: inventory and gate

The book aggregate

The aggregate book represents one title, not one physical copy. It is identified by its ISBN (identifiedBy: state.isbn) — the ISBN names a title, and a title appears exactly once in the catalog.

Its state:

FieldTypeMeaning
titlestringThe title
authorstringThe author
isbnstringThe title's identity
total-copiesnumberHow many copies do we own?
available-copiesnumberHow many are on the shelf right now?

Why a counter and not an available: boolean? Because a real library routinely holds several copies of the same title. "Available" isn't a property of the title — it's a number between 0 and total-copies, and only a counter can express partial availability ("2 owned, 1 on the shelf"). Note also that ISBN uniqueness needs no invariant: identifiedBy already guarantees one aggregate per ISBN.

The real business rule becomes the aggregate's invariant:

  • available-copies-non-negative"available-copies must never drop below 0: a copy can only be checked out if one is available."

This rule has business meaning, it's checkable inside the aggregate — and it is about to become our gate.

Commands and events

acquireacquired — the librarian (actor librarian) acquires a title, with a copy count:

command: acquire          # actor: librarian
data: { title, author, isbn, copies }
publishes: acquired       # same fields

A single acquire with copies: 2 catalogs two copies of "Clean Code" — not two separate calls.

check-outchecked-out — a member (actor member) checks out a copy:

command: check-out        # actor: member — THIS is the gate
data: { isbn, borrower, due-date }
publishes: checked-out    # same fields

This is the key design decision of the example: borrowing starts in the inventory context, not in the lending context — because the rule that guards it (available-copies-non-negative) lives at book. If no copy is available, check-out is rejectedbefore any loan comes into existence anywhere. Checked at the second step instead, the rejection would arrive too late: the loan would already be recorded, the model inconsistent.

check-inchecked-in — the counterpart, but without an actor:

command: check-in         # no actor — triggered by a policy
data: { isbn }
publishes: checked-in

No member calls check-in directly; it's the downstream bookkeeping step of a return (see the policies below). Commands without an actor aren't a flaw — they honestly mark the places where no human expresses an intent and the system choreographs instead.

Two actors named member?

The check-out command needs an actor member — but doesn't "the member" belong to the lending context? Both are true, and the resolution is classic context mapping:

Background — the same person, two models: Every Bounded Context models the outside world from its own perspective. To cataloging, a member is "someone who checks out copies". To lending, a member is "someone who has loans". So the actor member exists twice — once per BC, same name, different artifacts. That's not duplication; contexts don't share objects, they translate.

In cataloging there are therefore two actors: librarian (acquires and maintains inventory) and member (checks out).

Read model and query

The catalog read model (tabular) projects the book aggregate's three events:

EventProjection
acquiredInsert row: title, author, isbn, total-copies = copies, available-copies = copies
checked-outDecrement available-copies for the ISBN
checked-inIncrement available-copies for the ISBN

The query search-catalog (parameter: term) returns catalog rows — usable by librarian and member, because members search for themselves, naturally.

Bounded Context lending: pure bookkeeping

The loan aggregate

A loan is one single lending transaction: this copy, this member, this due date. Its identity is a generated UUID — deliberately not the ISBN, because the same title will have many loans over time.

State: isbn, borrower, due-date. Its invariant:

  • loan-active-for-return"A loan can only be returned once: return-book requires an active (not yet returned) loan."

An honestly loan-local rule — checkable from the event history of this one loan alone.

Commands and events

borrowborrowed — records the loan. No actor: borrow is triggered by a policy after check-out succeeded in the inventory context. There is nothing left to check here — the gate already decided.

command: borrow           # no actor — policy-triggered
data: { isbn, borrower, due-date }
publishes: borrowed

return-bookreturned — the return, and it starts here, with the member in the lending context:

command: return-book      # actor: member
data: { isbn, borrower }
publishes: returned

Why isn't the return "flipped" the same way as borrowing? The guiding principle again: the rule that guards a return is loan-active-for-return — and it lives at loan. Returning has no resource scarcity that book would need to check (a shelf doesn't get "full"). So for the return, lending leads and cataloging follows via policy.

The example's symmetry is not mirror symmetry — it's a reasoned one:

ActionGuarding invariantlives atso the action starts in
Borrowavailable-copies-non-negativebookcataloging (check-out)
Returnloan-active-for-returnloanlending (return-book)

Read model and query

active-loans (tabular) projects borrowed (insert row) and returned (remove the row matching isbn + borrower). The query my-loans (parameter: borrower, actor: member) answers "What do I currently have on loan?".

The policies: choreography in both directions

Two domain-wide policies connect the contexts — and because the gate sits on one side for borrowing and on the other for returning, they point in opposite directions:

  • on-checked-out-create-loan — listens to checked-out (cataloging), triggers borrow (lending). Inventory decided; lending writes it down.
  • on-returned-check-in — listens to returned (lending), triggers check-in (cataloging). Lending decided; inventory counts back up.

Background — at-least-once and idempotency: Both policies are declared with deliveryGuarantee: at-least-once and idempotency: downstream. Translated: a policy may deliver the same event more than once in failure cases, and the receiving side must cope. For check-in that means: if the command arrives twice, available-copies must still only be incremented once — the second call does nothing and publishes no event. That "nothing" is modelable, and we'll write a scenario for exactly that below.

The flow: the lifecycle as a story

In the Flow View the model reads as seven slices, left to right:

  1. acquire-book (cataloging) — librarianacquireacquired. Two copies enter the inventory.
  2. check-out (cataloging) — membercheck-outchecked-out. The gate.
  3. create-loan (lending) — policy → borrowborrowed. The bookkeeping follows.
  4. return-book (lending) — memberreturn-bookreturned. The return starts at the loan.
  5. check-in (cataloging) — policy → check-inchecked-in. The inventory counts back up.
  6. catalog-view (cataloging) — catalog + search-catalog, fed by acquired, checked-out, checked-in.
  7. loans-view (lending) — active-loans + my-loans, fed by borrowed, returned.

The story is causally correct: first the gate decides (slice 2), then the loan exists (slice 3). That ordering is exactly what the guiding principle buys you — and it's visible at a glance in the Flow View.

Scenarios: the model becomes testable

ESDM ships an extension (given-when-then/v1) that attaches features to a consistency unit — an aggregate or a read model — with scenarios written as Given/When/Then. Given is event history, When is a command (or query), Then is the expected events, a rejection, or a query result. The values are concrete example data, not schemas.

Feature acquiring-books (on aggregate book)

Scenario acquire-new-title — the happy path:

given: []                            # the title doesn't exist yet
when:
  command: acquire
  data: { title: Clean Code, author: Robert C. Martin,
          isbn: "9780132350884", copies: 2 }
then:
  events:
    - acquired: { title: Clean Code, author: Robert C. Martin,
                  isbn: "9780132350884", copies: 2 }

Scenario reject-check-out-when-no-copies-available — the gate at work:

given:
  - acquired:    { ..., copies: 1 }              # one copy
  - checked-out: { isbn: "9780132350884",
                   borrower: anna, due-date: 2026-08-01 }
when:
  command: check-out
  data: { isbn: "9780132350884", borrower: ben, due-date: 2026-08-15 }
then:
  rejection: { invariant: available-copies-non-negative }

Note how scenario name, command, and invariant tell one story: the when is the real actor command check-out, and the rejection names the exact invariant that guards it.

Scenario check-in-is-idempotent — the legitimate nothing:

given:
  - acquired:    { ..., copies: 1 }
  - checked-out: { isbn: "9780132350884", borrower: anna, ... }
  - checked-in:  { isbn: "9780132350884" }       # return already recorded
when:
  command: check-in
  data: { isbn: "9780132350884" }                # ...arrives a second time
then:
  events: []                                     # nothing happens — on purpose

then: events: [] is not an error but a statement: the trigger ran, and deliberately no event was produced. That is exactly what idempotency: downstream demands from the receiving side of an at-least-once policy.

Feature returning-books (on aggregate loan)

Scenario reject-return-when-loan-not-active:

given:
  - borrowed: { isbn: "9780132350884", borrower: anna, due-date: 2026-08-01 }
  - returned: { isbn: "9780132350884", borrower: anna }
when:
  command: return-book
  data: { isbn: "9780132350884", borrower: anna }
then:
  rejection: { invariant: loan-active-for-return }

A loan that has already been returned can't be returned again — the loan-local invariant in action.

Feature catalog-visibility (on read model catalog)

Scenario partially-checked-out-title-shows-remaining-copies:

given:                               # events from the whole model allowed
  - acquired:    { title: Clean Code, ..., copies: 2 }
  - checked-out: { isbn: "9780132350884", borrower: anna, ... }
when:
  query: search-catalog
  parameters: { term: Clean }
then:
  result:
    - { title: Clean Code, author: Robert C. Martin,
        isbn: "9780132350884", total-copies: 2, available-copies: 1 }

Two copies acquired, one checked out — the search still finds the title, with available-copies: 1. This is the payoff of the counter model: partial availability is representable.

What the example deliberately leaves out

A rule like "at most 5 active loans per member" is a reasonable business rule — but it spans many loan aggregates, and a consistency unit ends at its own event history. A rule that can't be checked from a single aggregate's history is a signal, not a defect: either the model needs a different cut (e.g. a membership aggregate that tracks a member's active loans), or the rule isn't an invariant at all but a business decision that lives with eventual consistency. ESDM's Dynamic Consistency Boundary (DCB) extension addresses exactly such cross-instance conditions and is deliberately out of scope here.

Cheat sheet: every artifact at a glance

BCArtifactKindNote
libraryDomain
catalogingbookAggregateID: isbn; invariant available-copies-non-negative
catalogingacquireacquiredCommand → Eventactor librarian; carries copies
catalogingcheck-outchecked-outCommand → Eventactor member; the gate
catalogingcheck-inchecked-inCommand → Eventno actor; policy-triggered, idempotent
cataloginglibrarian, memberActorsmember is cataloging's own model of the member
catalogingcatalog / search-catalogRead Model / Querycounter, not boolean
lendingloanAggregateID: uuid; invariant loan-active-for-return
lendingborrowborrowedCommand → Eventno actor; policy-triggered
lendingreturn-bookreturnedCommand → Eventactor member; the return starts here
lendingmemberActorlending's own model of the member
lendingactive-loans / my-loansRead Model / Query
domainon-checked-out-create-loanPolicychecked-outborrow
domainon-returned-check-inPolicyreturnedcheck-in

And the one principle, if you take away a single sentence:

Every action starts at the aggregate whose invariant guards it — the other side follows via policy.