Event Modeling
Overview
Event modeling captures a system as a left-to-right timeline connecting five elements: interfaces (real entry points), commands (what actors intend), events (facts that happened), read models (what the system knows), and automations (event-driven side effects).
The visualization uses two kinds of swim lanes:
- Actor lanes at the top — one row per actor (user, admin, external system), containing the real interfaces they use
- Aggregate lanes in the middle — one row per DDD aggregate / event stream, containing commands and events
Why Event Modeling
Most software projects suffer from feature explosion — each new feature adds coupling, and coupling makes the next feature more expensive. The cost curve steepens until deadlines slip, budgets explode, and teams build the wrong thing because of bad communication, wrong assumptions, and misunderstood requirements. Building the wrong thing is the most expensive mistake in any project.
The root cause is never technology. No amount of architectural refinement fixes a communication problem. Event modeling solves this by providing a shared language that engineers, business experts, and AI agents all understand — five elements, four patterns, read left to right. One model, one truth, readable by anyone.
When to Use
- Documenting an existing system's behavior
- Designing a new feature or system with event sourcing
- Understanding domain boundaries and bounded contexts
- Creating integration contracts between teams
- Planning vertical slices for parallel development
The Practice
Event modeling is capture, not design. The goal is to discover and record what happens in a system — the events, the commands that cause them, and the read models that make sense of them. The language is "identify," "discover," "capture" — never "design" or "architect."
The practice follows a natural progression:
- Storm — Brainstorm domain events on a timeline. No commands, no read models yet. Just "what happened?"
- Capture — Organize events into aggregates and chapters. Identify commands that cause events and read models that make sense of them.
- Specify — Write GWT tests with concrete example data for each slice. The tests ARE the specification.
- Deliver — Extract vertical slices for parallel development. Each slice is independently buildable and testable.
- Evolve — Update the model as production reveals gaps. New events, new slices, revised read models.
When extracting from existing code, you're doing Capture and Specify simultaneously — the code already tells you what commands produce what events. When modeling from scratch, start with Storm.
The V Pattern
Every state-change slice follows a V-shaped information flow:
- User sees a read model (green) — "here's what the system knows"
- User submits a command (blue) — "here's what I want to happen"
- System records an event (orange) — "here's what happened"
- Event updates the read model (green) — ready for the next step
The V shape is the heartbeat of the system. Read model feeds the UI, UI triggers a command, command produces an event, event updates the read model. Repeat. Every feature is one or more V's on the timeline.
Information Completeness
You cannot start implementation until the model is information complete. Information completeness is checked within each slice — every field must be traceable to a data source that exists inside that slice.
Two directions, both required:
Read model ← Event (projection): Every field displayed in a read model must trace back to a field in an event. If a view shows "total price," there must be an event carrying that data.
Event ← Inputs (provenance): Every field in an event must trace back to a data source within the slice. Those sources are:
- Command parameters — fields the actor provides (e.g.
PlaceOrder(customerId, items)) - Trigger event — for automation slices, the event that triggered the automation
- External event — for external event slices, the inbound event from outside the system
- Consumed read models — read models the slice reads to do its work, declared in GWT
givenclauses
- Command parameters — fields the actor provides (e.g.
If an event contains a field that doesn't appear in any of these sources, the model has an information gap — that field's data comes from somewhere not captured in the model. This is the most common information completeness mistake: an event field that materializes from a hidden data source.
Example — automation consuming a read model:
An OnOrderPlaced automation triggers ReserveInventory. The trigger event OrderPlaced carries orderId, items. But the automation also needs current stock levels to decide which warehouse to reserve from. The InventoryLevel read model (with sku, available, reserved) appears in the GWT given clause. Now InventoryReserved(orderId, items, warehouseId) is information complete: orderId and items come from the trigger, warehouseId comes from the consumed InventoryLevel read model.
Example — hidden data source (antipattern):
A StartSync command takes (bucket, path) from the operator, but the SyncStarted event records (bucket, path, remoteCount, localCount). Where do remoteCount and localCount come from? The command handler secretly reads remote and local state — but those data sources aren't declared anywhere in the slice. The fix: either move the computation to a downstream automation that explicitly consumes the read models, or add the consumed read models to the slice's GWT given clause so the data sources are visible.
Missing information blocks work. A field with no source might depend on another team's API with a 3-month backlog — discovering this during implementation instead of during modeling is the most expensive mistake you can make.
The event_model.py validator enforces both directions:
- Projection check (State Change / Automation) — every
read_modelsentry must share at least one field with the slice's events. - Projection check (State View) — strict — every field in a State View's read model must appear in the union of trigger event fields, consumed read model fields (from
reads), and the fields the slice'smappingfills. For multi-trigger State Views, the union of all trigger fields is used. - Provenance check — every event field must be connected to its inputs: the command, trigger(s), external event, consumed read model in
reads, or an entry inmapping. A field that traces to none of these is an error. - Automation command strictness — every field in an automation command must trace to a non-command source (trigger, polled or consumed read models, external event). Commands receive data; they don't invent it.
- Cross-reference check — every read model name in
readsorpollsmust exist in some other slice'sread_models.
A command lists what the actor sends. When the handler renames a field or computes one, say so in the slice's mapping (see "Fields the TypeScript model adds") instead of padding the command with output fields. Every event field then traces either by name or through the mapping.
Finish the Model
Event modeling earns its keep by moving discovery to the cheapest phase. A gap found while modeling costs a sentence; the same gap found during implementation costs a rewrite; found in production it costs an incident. Closing every loop now — so no one pays for it later — is the disciplined and genuinely lazy move.
When a thread leads somewhere, follow it to the end:
- An event feeds a read model or triggers an automation (see "Every event connects forward").
- A read model that tracks a condition the domain reacts to — a count nearing a limit, a status, a deadline — has a slice that acts on that condition. State that leads nowhere is a gap one level up from a dead-end event. If an
IllegalMoveTallyreaches the count that ends the game, some slice must consume that threshold. - A domain rule you uncover while modeling — "a second illegal move forfeits the game," "an invoice unpaid after 30 days is written off" — gets captured, not shelved.
Don't put domain truths to a vote. When the domain determines something and it traces to a source, model it — don't surface a real, traceable rule as an optional add-on. Flagging a rule you already understand instead of capturing it just relocates the decision, and the work, to a more expensive moment. Reserve questions for genuine forks: real disagreement, irreversible cost, or facts only a domain expert holds. Everything the domain already decides, you model.
The same discipline runs in reverse — invent nothing. No event, read model, or slice that isn't a real domain concept (see "Invented read model names"). Model every real thing; add nothing spurious. Both halves serve one goal: minimize the total work the system demands over its life.
Tooling: two ways in
Write the model as TypeScript in ts/, or write the JSON directly and hand it to event_model.py. The TypeScript assembles to that same JSON, so the validator and the renderer below apply either way.
Write TypeScript for a system you are about to build. The compiler and the assembler enforce most of this document: a reference is a value, so an unknown event cannot be written; a slice is a chain of calls, so an illegal combination of elements does not compile; a field nothing fills is an assembly error. The same model generates the protobuf for the service it describes. See ts/README.md.
Write the JSON directly when extracting a model from a system that already exists, especially one written in another language. The TypeScript names interfaces as RPC methods it will generate. Inventing those for a service written in Go or Kotlin is the hallucinated-interface antipattern below. An extracted model names the real entry points: POST /v1/orders, OrderService.PlaceOrder, a Kafka topic.
Tooling: event_model.py
The event_model.py script in this skill directory validates and renders the JSON. It enforces a Pydantic schema with invariant validation and renders SVG diagrams.
Commands
python event_model.py validate <model.json> # Validate schema + invariants
python event_model.py render <model.json> # Render to SVG
python event_model.py schema # Print JSON schema
python event_model.py init <model.json> # Create template
Schema Enforcement
The tool validates these invariants automatically:
- Events must be past tense (e.g. OrderPlaced, UserRegistered — not PlaceOrder)
- External events must be past tense (e.g. ExternalPaymentReceived — not ExternalReceivePayment)
- Commands must be imperative (e.g. PlaceOrder, RegisterUser — not OrderPlaced)
- Automations must have a trigger — every gear is driven by an event (
trigger) or a TODO-list read model (polls) - GWT tests must include concrete example data — real values like
userId=42, amount=59.98 - Actor and aggregate references must exist in the model's actor/aggregate lists
- Read model fields must overlap with events — at least one field in a read model must exist in the slice's events. Additive events (e.g. ItemAdded) carry all view fields; destructive events (e.g. ItemRemoved, CartCleared) carry only identifying fields needed to update or delete rows
JSON Model Structure
{
"name": "Order Management",
"source_base": "https://github.com/org/repo/blob/main",
"actors": [
{ "id": "customer", "name": "Customer", "type": "user" },
{ "id": "warehouse", "name": "Warehouse System", "type": "system" },
{ "id": "payments", "name": "Payment Provider", "type": "external" }
],
"aggregates": [
{ "id": "order", "name": "Order" },
{ "id": "inventory", "name": "Inventory" }
],
"chapters": [
{
"name": "Order Lifecycle",
"slices": [
{
"name": "Place Order",
"actor": "customer",
"aggregate": "order",
"ui": "POST /v1/orders",
"command": "PlaceOrder(customerId, items, shippingAddress)",
"events": ["OrderPlaced(orderId, customerId, items, total)"],
"read_models": ["OrderSummary(orderId, customerId, status, total)"],
"tests": [
{
"name": "Customer places order with two items",
"given": [],
"when": "PlaceOrder(customerId=c-42, items=[sku-1, sku-2], shippingAddress=123 Main St)",
"then": ["OrderPlaced(orderId=ord-100, customerId=c-42, items=[sku-1, sku-2], total=59.98)"]
}
]
},
{
"name": "View Order",
"actor": "customer",
"aggregate": "order",
"ui": "GET /v1/orders/{orderId}",
"read_models": ["OrderSummary(orderId, customerId, status, total)"],
"tests": []
}
]
},
{
"name": "Fulfillment",
"slices": [
{
"name": "Reserve Inventory",
"actor": "warehouse",
"aggregate": "inventory",
"automation": "OnOrderPlaced",
"trigger": "OrderPlaced",
"command": "ReserveInventory(orderId, items)",
"events": ["InventoryReserved(orderId, items, warehouseId)"],
"read_models": ["InventoryLevel(sku, warehouseId, available, reserved)"],
"tests": [
{
"name": "Inventory reserved after order placed",
"given": [
"OrderPlaced(orderId=ord-100, items=[sku-1])",
"InventoryLevel(sku=sku-1, available=50, reserved=0)"
],
"when": "ReserveInventory(orderId=ord-100, items=[sku-1])",
"then": ["InventoryReserved(orderId=ord-100, items=[sku-1], warehouseId=wh-east)"]
}
]
}
]
},
{
"name": "External Payment Events",
"slices": [
{
"name": "Confirm Payment",
"actor": "payments",
"aggregate": "order",
"external_event": "ExternalPaymentReceived(orderId, amount, transactionId)",
"command": "ConfirmPayment(orderId, amount, transactionId)",
"events": ["PaymentConfirmed(orderId, amount, transactionId)"],
"read_models": ["OrderSummary(orderId, customerId, status, total)"],
"tests": [
{
"name": "Payment confirmed updates order status",
"given": ["ExternalPaymentReceived(orderId=ord-100, amount=59.98, transactionId=txn-7)"],
"when": "ConfirmPayment(orderId=ord-100, amount=59.98, transactionId=txn-7)",
"then": ["PaymentConfirmed(orderId=ord-100, amount=59.98, transactionId=txn-7)"]
}
]
}
]
}
]
}
Slice Types
Every slice has an optional name field — a short human-readable label (e.g. "Place Order", "OAuth Callback"). When present, names render as column headers in the SVG diagram and appear in validation error messages. Always provide a name.
The model supports three kinds of slices:
| Slice Type | Required Fields | Description |
|---|---|---|
| Command slice | ui + command + events |
Actor uses an interface to issue a command that produces events |
| External event slice | external_event + command + events |
An event from outside the system boundary triggers an internal command |
| View-only slice | ui + read_models (no command/events) |
Actor queries a read model through an interface — no state change |
| Automation slice | automation + (trigger or polls) + command + events |
An event, or a TODO-list read model, drives an automated command |
| State View slice | trigger + read_models (no command/events/ui) |
Event triggers a read model projection |
Automations: two forms
An automation issues a command with no actor. One of two things drives it.
| Form | JSON | TypeScript | Use it when |
|---|---|---|---|
| event-driven | trigger names the event |
.on(Event) |
One event is enough to decide, and the work is quick and safe to repeat |
| todo list | polls names a read model |
.polls(ReadModel) |
The work is slow, can fail, calls another system, or must not run twice |
A todo list is a read model of the work to do. A projection adds a row when the work becomes due and marks it done when the event that finishes it arrives. The processor reads the list on its own schedule and issues the command for each open row. This buys three things:
- Idempotence — a row marked done is not processed again, so a retry or a restart is safe.
- Consistency — the list is built from events, not from the processor's memory, so it is right after a crash.
- A state you can reason about — the list shows what is pending, what is done, and what is stuck.
An event-driven automation has none of this. If the handler fails after the event, the work is lost unless the transport replays it.
{
"name": "PaymentProcessor",
"actor": "system",
"aggregate": "payment",
"automation": "PaymentProcessor",
"polls": "PaymentsToProcess",
"command": "ProcessPayment(bookingId, amount)",
"events": ["PaymentSucceeded(bookingId, amount)"],
"tests": []
}
The read model in polls must exist in some slice's read_models. Its fields are inputs to the command, like reads.
Fields the TypeScript model adds
The TypeScript model emits four fields beyond the example above. Hand-written JSON may use them too.
query(view slice) — the request fields of a view, drawn under the method on the interface card:"ui": "TodoService/GetList", "query": ["listId"].polls(automation slice) — the TODO-list read model that drives the automation, in place oftrigger.note(slice) andnotes(model, declaration name to text) — prose attached to a slice or to a declaration. The renderer shows a note as a tooltip and marks the card with ⓘ.mapping(slice) — per event, the fields the handler fills that do not flow by name, and where each came from:{"from": "text"}for a renamed field,{"value": 0}for a constant,{"count": true}for a tally of that event. A mapped field counts as traced in the provenance checks.
{
"command": "AddItem(listId, itemId, text)",
"events": ["ItemAdded(listId, itemId, title)"],
"mapping": { "ItemAdded": { "title": { "from": "text" } } }
}
State View with reads: A State View can declare reads to access data from consumed read models in addition to its trigger event. The trigger is the availability signal ("this data is ready"); the reads provide the actual data. The validator checks that every read model field appears in the union of trigger fields AND consumed read model fields.
{
"name": "Project Archive Games",
"trigger": "PeriodExported(period, gameCount, gcsPath)",
"reads": ["BufferedGames"],
"read_models": ["ArchiveGames*(*period, gameCount, gcsPath, gameKey, endedAt, ...)"]
}
Multi-trigger State Views: A State View's trigger field can be a single event string or a list of event strings. When a read model accumulates data from multiple event types (common in CQRS projections), list all the events the projection subscribes to. The validator checks that every read model field appears in the union of all trigger event fields.
Cardinality Notation
Read models and fields use * to indicate cardinality and aggregate pointers:
ReadModel*— suffix*on the name indicates a collection (many rows), not a singleton*field— prefix*on a field name indicates an aggregate pointer / partition key
"read_models": ["BufferedGames*(*period, gameKey, endedAt, startedAt, variant, ...)"]
This says: BufferedGames is a collection partitioned by period, containing game records with all their fields. Downstream slices that reads: ["BufferedGames"] have access to the full schema.
Processor Naming
Automation slices should use descriptive processor names that describe WHAT the automation does, not WHAT triggers it. The trigger event is already shown via the trigger field.
| Instead of | Use |
|---|---|
OnOrderPlaced |
InventoryReserver |
OnPeriodAggregated |
PeriodExporter |
OnMonthPeriodsDownloaded |
MonthEncoder |
The On* naming duplicates information that's already in the trigger field and tells the reader nothing about what the processor actually does.
Source Code Annotations
Cards can link to source code with the refs field on slices and source_base on the model. Keys are the full element label (matching command, events[], read_models[], ui, external_event). Values are lists of {label, path} objects.
source_base(model-level, optional): URL prefix for relative paths (e.g."https://github.com/org/repo/blob/main")refs(slice-level, optional):dict[str, list[SourceRef]]mapping element labels to annotations- Single ref: card becomes clickable (
<a href>) with↗indicator and tooltip - Multiple refs: annotation panel renders below the card with clickable
↗ Labellinks - Layout: aggregate lanes and actor lanes expand automatically when annotation panels are present. Models without refs render identically to before.
{
"refs": {
"AddItem(aggregateId, itemId, productId)": [
{ "label": "Command", "path": "src/main/kotlin/.../AddItemCommand.kt#L7" },
{ "label": "Handler", "path": "src/main/kotlin/.../CartAggregate.kt#L35" }
],
"ItemAdded(aggregateId, itemId, productId)": [
{ "label": "Event", "path": "src/main/kotlin/.../ItemAddedEvent.kt#L8" }
]
}
}
Paths can be relative (resolved against source_base) or absolute URLs (https://..., vscode://...).
SVG Diagram Layout
The rendered diagram uses this vertical layout:
- Title — model name
- Chapter headers — blue bars spanning each chapter's columns
- Actor swimlanes — one row per actor, containing:
- UI/interface cards (grey) for internal entry points
- External event cards (orange) for events arriving from outside the system boundary
- Automation gears (purple) for event-driven side effects
- Read model row — green cards near the top (they feed the interfaces above), shifted right to clear down-flow arrows
- Aggregate swim lanes — horizontal bands per DDD aggregate, containing:
- Command cards (blue) in the upper part
- Event cards (orange) in the lower part
- GWT tests — Given/When/Then cards aligned under each slice, with wrapped test names
Arrow Semantics
- Interface → Command: solid grey down arrow from actor lane to aggregate lane (actor initiates)
- External Event → Command: dashed orange down arrow from actor lane to aggregate lane (external system pushes data in)
- Automation → Command: solid purple down arrow from actor lane to aggregate lane (gear triggers)
- Command → Event: solid grey down arrow within aggregate lane
- Event → Read Model: dashed green up arrow from aggregate lane to read model row (projection)
- Read Model → Interface: dashed green up arrow (feeds screen, view-only slices)
- Event → Automation: L-shaped dashed purple arrow from event up to gear in actor lane
- NEVER event → event — events are facts, they don't cause each other
- Down-flow arrows offset left (35%), up-flow arrows offset right (65%) — prevents visual overlap
Process
Phase 0: Event Storm (Greenfield or Discovery)
When modeling a new system or discovering behavior before reading code, start by brainstorming events:
- List everything that happens in the domain — past tense facts like
OrderPlaced,PaymentReceived,ItemShipped - Arrange events on a left-to-right timeline
- Sketch rough screens — ugly wireframes showing what users see and what buttons they click. Spend no more than 2 minutes per screen. Screens drive read model discovery: what information does the user need to see?
- Don't worry about commands, read models, or aggregates yet — just events and screens
- Group related events to discover natural aggregate boundaries
This phase is optional when extracting from code (Phase 1 covers discovery through code reading), but valuable when:
- Starting a new feature with no existing code
- Onboarding domain experts who think in terms of "what happens"
- The codebase is too large to read first — brainstorm then validate against code
Phase 1: Clone and Read the Code (Extraction)
When extracting from existing code, this is the foundation. You MUST read the actual source code before modeling.
- Clone the repository into
./workspace/ - Read entry points: REST controllers, RPC/gRPC service implementations, Kafka consumers, message handlers
- Identify actors: users, admins, external systems, scheduled jobs
- Identify aggregates: the core entities whose state changes matter
- List the core workflows as user stories (beginning, middle, end)
Phase 2: Identify Real Interfaces and External Events
NEVER hallucinate interface names. Use ui for internal entry points and external_event for inbound events from outside the system boundary.
Internal interfaces (ui field) — entry points the system exposes:
| Interface Type | Example | What to Look For |
|---|---|---|
| REST endpoint | POST /v1/orders |
Controller classes, route definitions |
| gRPC/RPC service | OrderService.PlaceOrder |
Proto files, service implementations |
| Cron/scheduled job | CronJob: daily-cleanup |
Scheduler configs, job classes |
| Query endpoint | GET /v1/orders/{orderId} |
Read-only controllers (view-only slices) |
External events (external_event field) — events arriving from outside the system boundary:
| Source | Example | What to Look For |
|---|---|---|
| Kafka topic | ExternalPriceChanged(productId, newPrice, oldPrice) |
Kafka consumers, message handlers |
| Webhook | ExternalPaymentReceived(orderId, amount) |
Webhook controllers, event handlers |
| Message queue | ExternalAccountCreated(userId, username) |
SQS/RabbitMQ consumers |
Key distinction: If an external system pushes data into your system via a message/event, use external_event (rendered as an orange card with a dashed orange arrow). If a user or system calls an endpoint you expose, use ui (rendered as a grey card with a solid grey arrow).
Phase 3: Walk the Happy Path
For one workflow at a time, trace from trigger to completion:
| Element | Convention | Example |
|---|---|---|
| Interface | Real entry point from code | POST /v1/orders |
| External Event | CamelCase past tense, prefixed with External | ExternalPaymentReceived(orderId, amount) |
| Command | CamelCase imperative, with schema | PlaceOrder(customerId, items) |
| Event | CamelCase past tense, with schema | OrderPlaced(orderId, items, total) |
| Read Model | CamelCase noun, with schema | OrderSummary(orderId, status, total) |
| Automation | CamelCase descriptive processor name | InventoryReserver |
Phase 4: Organize
- Group events by aggregate (Order, Inventory, Payment, etc.) — these become swim lanes
- Arrange on timeline left-to-right
- Group related slices into chapters (Order Lifecycle, Fulfillment, etc.)
- One command per slice — if there are 2 commands, the slice is too big
Phase 5: Define Test Specifications
Define GWT tests collaboratively with business experts — not in isolation. Each slice gets GWT tests with concrete example data — real IDs, real numbers, real outcomes. Business knowledge becomes an executable specification in code:
{
"name": "Inventory reserved after order placed",
"given": [
"OrderPlaced(orderId=ord-100, items=[sku-1])",
"InventoryLevel(sku=sku-1, available=50, reserved=0)"
],
"when": "ReserveInventory(orderId=ord-100, items=[sku-1])",
"then": [
"InventoryReserved(orderId=ord-100, items=[sku-1], warehouseId=wh-east)"
]
}
Projection check: For each read model in a slice, verify that the read model shares at least one field with the slice's events. Additive events (e.g. ItemAdded) should carry all the data the view needs. Destructive events (e.g. ItemRemoved, CartCleared) only need identifying fields — enough to locate and remove rows. The validator enforces field overlap automatically.
Phase 6: Generate the Diagram
python event_model.py render model.json -o model.svg # from JSON
npx em render model/ -o model.svg # from the TypeScript model
Open the SVG in a browser to review. Iterate on the model until the diagram clearly tells the story of the system.
Phase 7: Extract Vertical Slices for Delivery
Each slice in the model is an independently buildable and testable unit of work:
- Identify slices with no dependencies on other slices — these can be built first
- Map automation triggers to find natural ordering — a slice producing an event that triggers an automation defines a build dependency
- Assign independent slices to developers for parallel implementation
- Use the GWT tests from Phase 5 as acceptance criteria for each slice
Phase 8: Evolve the Model
Event models are living documents. When production reveals gaps:
- Observe unexpected behavior or missing capabilities in the running system
- Add new events, commands, or slices to the model to capture the gap
- Re-validate and re-render to verify the model still tells a coherent story
- Treat the updated model as the specification for the fix
Slices as Work Items
Each slice in the event model is a natural work item — independently buildable, testable, and assignable. The event model IS the backlog. Detailed Jira tickets are unnecessary when every slice already captures the interface, command, events, read models, and GWT acceptance criteria.
Slices follow a simple status progression: planned → in progress → done. Pick the next planned slice, implement it, verify the GWT tests pass, mark it done. The model tracks what's been built and what remains.
Because slices are independent — events are the only contract between them — multiple developers or agents can work on different slices in parallel without conflicts. Adding more people to the project makes it faster, not slower.
AI Agent Integration
Event models are the perfect input for AI code generation. Each slice is exactly the right level of abstraction for an AI agent — small enough to fit in context, specific enough to produce correct code, and testable via GWT.
The combination works like this:
- Static code generation handles the repeatable boilerplate — every slice produces the same predictable structure (route, command handler, event, projection, test file)
- AI agents handle the business logic — the part that varies between slices, guided by GWT tests as guardrails
- GWT tests are executable specifications — defined collaboratively with business experts, they become the acceptance criteria that verify the agent built what was specified
Each slice type (state change, state view, automation) follows a known pattern. Agents don't need to invent architecture — they fill in the business-specific details within a well-defined structure.
Key Invariants
- Events are immutable facts in past tense — never update, only append
- Commands are imperative intents — each produces one or a small cluster of events
- Read models are pure functions of events — given same events, same view
- Projections are State View slices — each read model projection gets its own State View slice with
trigger(the event it subscribes to) andread_models(what it builds). State Change slices produce events; State View slices project events into read models.readslists read models consumed by the slice's command (inputs from other slices). The read model names inreadsmust exist in some State View slice'sread_models. - Event fields must trace to declared sources — every field in an event must come from: (1) the command, trigger, or external event, (2) a consumed read model declared in
reads, or (3) a produced read model of the same slice (transformation output). The validator enforces this exhaustively. If a command handler reads external state, that state must be declared inreads. - Not every event needs a read model — terminal events that cross the system boundary outward (e.g. publishing to a Kafka topic for other bounded contexts) have no projection within this system. They are valid dead ends on the timeline.
- Read models feed interfaces — they belong near the top, close to the entry points they serve
- External events represent inbound data from outside the system — Kafka topics, webhooks, message queues. Use
external_event(notui) for these. They render as orange cards with dashed orange arrows. - View-only slices show read model consumption —
ui+read_modelswithout command/events. They render a green dashed arrow from read model up to the interface. - Actor swimlanes show real interfaces — REST endpoints, RPC methods — NEVER made-up screen names
- Aggregate swimlanes contain commands and events — grouped by DDD aggregate / event stream
- Automations are driven by events or TODO lists — never standalone;
triggernames the event,pollsnames the TODO-list read model (see "Automations: two forms") - Every state change appears on the timeline — no hidden triggers or side effects
- Names are CamelCase — no spaces, e.g.
PlaceOrdernotPlace Order - GWT uses concrete data — real IDs, real numbers, real outcomes
- Business language only — no "DAO persisted" or "saga timed out"
- Each slice is independently buildable and testable — events are the only contract between slices. No slice depends on another slice's internals. This independence enables parallel work by multiple developers or AI agents.
- One command per slice — if there are 2 commands, break it into 2 slices
- Every event connects forward — each event should either feed a read model (projection) or trigger an automation. Events that connect to nothing are dead ends (unless they cross the system boundary outward, e.g. publishing to Kafka for another bounded context).
- State connects forward too — a read model that tracks a condition the domain acts on (a threshold, status, or deadline) must have a slice that consumes it. A tally that reaches a process-ending count with nothing acting on it is a gap, exactly like a dead-end event.
- Finish the model — don't put domain truths to a vote — when the domain determines something and it traces to a source, capture it. Modeling is the cheap phase to close gaps; a rule you noticed but shelved as "optional" resurfaces during implementation, the most expensive place to find it. Reserve questions for genuine forks, not for work you can already do.
- Read models grow progressively — a single read model can accumulate fields as more events project into it. Don't invent separate read models per phase when one growing projection captures the domain.
- Capture, don't design — event modeling discovers what happens in a system. Use language like "identify," "capture," "discover" — not "design" or "architect." The model records reality, it doesn't prescribe it.
Plain English
- event: "something that happened in the system"
- external event: "something that happened outside the system that we need to react to" — arrives via Kafka, webhook, message queue
- command: "something we want to happen in the system"
- read model: "making sense of what happened in the system"
- interface: "the real entry point where actors interact with the system" — a REST endpoint, RPC method, or cron job
- automation: "side effect of what happens in the system" — always driven by an event or TODO list, always issues a command
Antipatterns
- Hallucinated interfaces — using made-up screen names like "OrderDashboard" instead of the actual
POST /v1/ordersendpoint - Using
uifor external events — if a Kafka consumer or webhook receives data from another system, useexternal_eventnotui. The arrow direction and rendering are different. - One command always producing multiple events simultaneously (suggests the events should be combined into one). Note: alternative outcomes from one command (success/failure) are NOT an antipattern — model the primary outcome in
eventsand capture alternatives in GWTthenclauses. - One slice with multiple commands
- Many events updating the same read model
- Events with arrows into other events (events don't cause each other)
- Read models at the bottom of the diagram (they feed interfaces, put them near the top)
- Swim lanes for element types (Commands/Events/ReadModels) instead of aggregates
- Names with spaces instead of CamelCase
- GWT with placeholder schemas instead of concrete example data
- Standalone automations not driven by an event or TODO-list read model
- "The thing" — too much business logic in a single slice
- Dead-end events — events that don't feed any read model or trigger any automation, with no justification (terminal boundary events are the exception)
- Invented read model names — read models should use domain terms the team already uses. If you can't name it from the domain vocabulary, it may not be a real concept.
- Inline projections — putting read models as outputs of state change slices instead of giving each projection its own State View slice. Projections are their own slices: a State View with a
trigger(the event it subscribes to) andread_models(what it builds). State Change slices produce events; State View slices project events into read models. - Hidden data sources in events — an event field that doesn't trace to any input within the slice (command, trigger, external event, or consumed read model in
given). Example:SyncStarted(bucket, path, remoteCount)whereremoteCountcomes from querying an external system that isn't declared as a consumed read model. Fix: add the consumed read model to the GWTgiven, or move the computation to a downstream automation that explicitly consumes the read model. - "Design" language — saying "design the read models" or "architect the commands." Event modeling captures what happens; it doesn't prescribe what should happen.
- Dead-end state — a read model that tracks a condition the domain reacts to (a count nearing a limit, a status, a deadline) with no slice that consumes it. The same defect as a dead-end event, one level up.
- Punting a known rule — flagging a real, traceable domain rule as an optional add-on instead of modeling it. Deferring a rule you already understand just moves the work to a more expensive phase. Model it; report what you modeled.