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:
- 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.
- 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 gatelending— 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:
| Field | Type | Meaning |
|---|---|---|
title | string | The title |
author | string | The author |
isbn | string | The title's identity |
total-copies | number | How many copies do we own? |
available-copies | number | How 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
acquire → acquired — 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-out → checked-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 rejected — before 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-in → checked-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". Tolending, a member is "someone who has loans". So the actormemberexists 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:
| Event | Projection |
|---|---|
acquired | Insert row: title, author, isbn, total-copies = copies, available-copies = copies |
checked-out | Decrement available-copies for the ISBN |
checked-in | Increment 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
borrow → borrowed — 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-book → returned — 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:
| Action | Guarding invariant | lives at | so the action starts in |
|---|---|---|---|
| Borrow | available-copies-non-negative | book | cataloging (check-out) |
| Return | loan-active-for-return | loan | lending (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 tochecked-out(cataloging), triggersborrow(lending). Inventory decided; lending writes it down.on-returned-check-in— listens toreturned(lending), triggerscheck-in(cataloging). Lending decided; inventory counts back up.
Background — at-least-once and idempotency: Both policies are declared with
deliveryGuarantee: at-least-onceandidempotency: downstream. Translated: a policy may deliver the same event more than once in failure cases, and the receiving side must cope. Forcheck-inthat means: if the command arrives twice,available-copiesmust 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:
acquire-book(cataloging) —librarian→acquire→acquired. Two copies enter the inventory.check-out(cataloging) —member→check-out→checked-out. The gate.create-loan(lending) — policy →borrow→borrowed. The bookkeeping follows.return-book(lending) —member→return-book→returned. The return starts at the loan.check-in(cataloging) — policy →check-in→checked-in. The inventory counts back up.catalog-view(cataloging) —catalog+search-catalog, fed byacquired,checked-out,checked-in.loans-view(lending) —active-loans+my-loans, fed byborrowed,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
| BC | Artifact | Kind | Note |
|---|---|---|---|
| — | library | Domain | |
| cataloging | book | Aggregate | ID: isbn; invariant available-copies-non-negative |
| cataloging | acquire → acquired | Command → Event | actor librarian; carries copies |
| cataloging | check-out → checked-out | Command → Event | actor member; the gate |
| cataloging | check-in → checked-in | Command → Event | no actor; policy-triggered, idempotent |
| cataloging | librarian, member | Actors | member is cataloging's own model of the member |
| cataloging | catalog / search-catalog | Read Model / Query | counter, not boolean |
| lending | loan | Aggregate | ID: uuid; invariant loan-active-for-return |
| lending | borrow → borrowed | Command → Event | no actor; policy-triggered |
| lending | return-book → returned | Command → Event | actor member; the return starts here |
| lending | member | Actor | lending's own model of the member |
| lending | active-loans / my-loans | Read Model / Query | |
| domain | on-checked-out-create-loan | Policy | checked-out → borrow |
| domain | on-returned-check-in | Policy | returned → check-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.