Case study · event storming a real codebase
The event-storming primer ran the workshop on an invented domain. This time we run it on a real, in-production app: ClubCP, a club & community platform (events, tickets, memberships, payments, messaging). Every sticky below is traced from the actual code, and the board grows as you scroll until ClubCP's real bounded contexts emerge. Companion to S8 · Domain-Driven Design.
You usually event-storm before building, to find boundaries. But the same workshop is the fastest way to understand a system you inherited. Here we reverse-engineer ClubCP from its routes, models, and background jobs: the timeline of events is the business; the clusters are the seams the code already has (or should have). Everything is grounded in real files like registerForEvent.api.js, executePaymentV2.api.js, and sendNoShowEmails.js.
Same notation as the primer; nothing new to learn. Each colour has one meaning. The whole page just adds these one at a time, with ClubCP's real names on them.
Domain event orange
Something that happened, in past tense. The spine of the board.
Command blue
An intention that causes an event. Maps to an API route.
Aggregate yellow
The model that enforces the rules and emits the event (a Sequelize model).
Actor small yellow
A role that issues a command. The who.
External system pink
A third party ClubCP calls or that calls back (payments, email, SES).
Policy lilac
A reactive rule, mostly the background jobs & schedulers. The glue.
Read model green
The view an actor reads to decide their next command.
Hotspot pink, rotated
A real risk or sharp decision in the code. Park it loudly.
a actor reads a read model, issues a command → a model/aggregate checks its rules and emits a domain event → a policy (a job) reacts and fires the next command (often via an external system).
In ClubCP terms: a Member reads an event detail page, hits Register; the TicketUser model checks capacity and emits Ticket Reserved; a job queues a confirmation email through SendInBlue. That loop is the whole app.
A free club / community-management platform: a club publishes events, sells tickets and memberships, messages its members, and takes payment per club. Real stack: a Next.js frontend and an Express + Postgres (Sequelize) backend, with BullMQ/Redis jobs, deployed on Coolify. clubcp.app.
The slice we storm
ClubCP is large. We bound the storm to its busiest journey: "from publishing an event to chasing the no-shows", i.e. a member buying a ticket to a paid, capacity-limited event and attending it. That one path touches ticketing, payments, and email, ClubCP's three hardest contexts.
Why it's a good subject
It has the things that make event storming pay off: money (dual payment providers), contention (ticket capacity & a waiting list), approval state, and a swarm of async jobs (auto-publish, no-show emails, bounce handling). Lots of real hotspots.
Read the routes & models · in silence
Walking ClubCP's routes and Sequelize models, we throw every meaningful past-tense thing that happens onto orange stickies, no order yet. Even this small sample shows how much one "club app" really does.
Board state · real ClubCP events, unordered
Past tense forces honesty: it's "Payment Captured", not "capture payment". And the spread of events, from Membership Purchased to Email Bounced to Broadcast Sent, hints at several different businesses living in one repo. We'll bound the storm to one journey, but note the others for later: each is a likely context of its own.
Order the spine · the member's journey
We line up the six events on the path we chose, left to right. This is the real ClubCP happy path: an event goes live, a member reserves a spot, pays, shows up, and the system follows up with anyone who didn't.
✓ Board state · the spine on one timeline
Does Ticket Reserved come before or after payment? In ClubCP's code it does both: a free event reserves immediately (registerForEvent.api.js), but a paid ticket creates a DRAFT transaction first and only becomes a confirmed reservation once Payment Captured flips it to ACTIVE. That split, free vs paid registration, is a genuine fork the timeline forced us to name. We follow the paid path.
Mark the pain · pink diamonds
ClubCP's real sharp edges, parked as hotspots so they don't derail the flow. The thick dividers are pivotal events where the process changes hands, and they line up exactly with where the contexts will split.
✓ Board state · + hotspots new + pivotal markers
payment_id on a DONE transaction (migration unique-payment-id-when-done), so a replayed capture is idempotent, the S6 idempotency lesson, shipped.registerForEvent.api.js locks the ticket row, sums count_adult + count_children against max_members, and either reserves or pushes to WAITING with a sequential position.sendNoShowEmails.js atomically claims the event (no_show_emails_sent: false → true in one UPDATE) so two concurrent job runs can't both send.executePaymentV2 re-checks the club block-list at capture time, even after the provider says "paid".Add the who & the vendors
Who triggers each event (small yellow), and which third party is involved (pink). ClubCP's payment, email, and bounce vendors all show up here, and so does a non-human actor: the scheduler.
✓ Board state · + actors & external systems new
The actors change across the timeline: an Event Editor sets up, a Member buys, Door Staff scan, and a Scheduler follows up, four different hands, exactly S8's "different people own it" signal. And the external systems cluster: payments (PayPal/Stripe) in the middle, email (SendInBlue + AWS SES bounce webhooks) at the end. Those vendor clusters are where you'll want anti-corruption layers so a provider's model never leaks into ClubCP's.
Add the intentions · blue
Each blue command is, almost literally, a route in the Express app. Reading command → event as a sentence checks the model: a Member hits Register for Event and the result is Ticket Reserved.
✓ Board state · + commands new
Real routes: POST /ticket/event/:eventId/register, POST /payment/sendTicketV2 → POST /payment/executeTicketV2, POST /event/:eventId/check-in-by-qr. The last command has no human actor, Run No-Show Scan is fired by a timer, a tell that a policy is driving it (step 7).
Add the models · big yellow
Between command and event sits the aggregate, the model that enforces the invariant and emits the event. These are ClubCP's actual models. Where one model handles several steps in a row, you've found a cluster.
✓ Board state · + aggregates new
Four model-groups appear. Ticket · TicketUser protect the capacity invariant (sum of seats ≤ max_members, checked under a row lock). Transaction protects the money invariant (one DONE per payment_id). Crucially, capacity and money live in different aggregates, so "reserve then pay" can't be one transaction, it's a small saga, which is exactly why a paid reservation sits in DRAFT until capture confirms it. The hotspot and the model map agree again.
Add the glue · lilac
A policy is a "whenever… then…" rule with no human in the loop, and in ClubCP these are literally the BullMQ jobs and the timers. They're how one event automatically drives the next command.
✓ Board state · + policies new (under the event they react to)
Notice that two policies end in "queue an email". That email send is itself a whole sub-machine: enqueueEmail → BullMQ priority tiers → emailWorker → SendInBlue → and asynchronously, AWS SES bounce/complaint webhooks that write the suppressedEmail list so future sends skip a dead address. The lilac notes are the seam where Ticketing hands off to Communications, a context that reacts to events from everywhere and couples to no one.
Add the views · green
Every command starts with someone reading a screen. The green read model is that screen, and naming them shows which data each part of ClubCP must serve.
✓ Board state · + read models new (above the actor who reads them)
The Email batch dashboard (sent / bounced / complained / suppressed counts per campaign) is the read model an admin uses to trust the no-show run actually landed, the green note that makes Communications a context worth its own screens, not just a fire-and-forget helper.
Trace the clusters
Group by where the language, the actor, the model, and the vendor change together, and draw the boxes. ClubCP's real seams appear, and one of them teaches a lesson the invented domain couldn't.
✓ Final board · the contexts, and the Payments interleave new
Events & Ticketing
Event · Ticket · TicketUser · capacity & waitlist
Payments
Transaction · dual provider · idempotent capture
Events & Ticketing
door ops: check-in (same context, returns)
Communications
EmailBatch · SendInBlue · SES suppression
Events & Ticketing appears on both sides of Payments. The ticketing flow hands control to Payments to take the money, then takes it back for check-in. That interleaving is the boundary: Payments is a service the ticketing flow calls into and gets a result from, which is precisely why ClubCP keeps payment behind its own sendTicketV2/executeTicketV2 pair with its own dual-provider config and idempotency, rather than scattering Stripe calls through the ticket code. Three contexts emerged from the wall: Events & Ticketing · Payments · Communications, plus a fourth, Club & Membership, that sits underneath owning the per-club payment config and the block-list these flows consult.
Each context owns its models and talks to the others through events and thin contracts. This is the map you'd hand a new engineer, or use to argue a service split in an ADR.
| Bounded context | Owns (models) | Emits (events) | Reacts to (policy / job) |
|---|---|---|---|
| Events & Ticketing | Event, Ticket, TicketUser, TicketTemplate, EventTicketAlert | Event Published, Ticket Reserved, Added to Waiting List, Approval Granted, Attendee Checked In | auto-publish at auto_publish_at · capacity → waitlist · no-show scan claims the event |
| Payments | Transaction (+ Club's provider config) | Payment Authorized, Payment Captured, Payment Failed | on Captured → activate TicketUser/Membership + queue receipt · re-check block-list |
| Communications | EmailBatch, EmailDelivery, SuppressedEmail, Conversation, Message, Broadcast | Email Sent, Email Bounced, Email Suppressed, Broadcast Sent | BullMQ priority queue · SES bounce/complaint webhook → suppress · batch completion |
| Club & Membership | Club, Membership, MembershipPlan, User, ClubBlock, InboxAccess | Club Created, Membership Purchased, Member Role Changed, User Blocked | membership-expiry reminders · provides payment config & block-list to Payments |
This table is the evidence an ADR #2 needs. "Payments is its own context because the language (charge, capture, refund), the actor (the provider), the data lifecycle (a transaction's DRAFT→DONE), and the failure mode (a double-charge) all change at Payment Authorized, and the only coupling back to Ticketing is two events." That's a boundary justified by a real seam, exactly what P3 · ADR #2 asks for. Whether each context is a separate service or a module is still the S7 call (ClubCP runs them as modules in one Express app today, and the seams are clean enough to extract Payments first if scale ever demanded it).
What the exercise surfaced in ClubCP
DRAFT → capture → ACTIVE), not one transaction. The code already reflects this; the wall makes it obvious.Interview Q&A
Q · Why event-storm an app that already works?
To recover the model. Routes and tables tell you what the code does; the event timeline tells you why. It's the fastest way to onboard, to find the real seams for a service extraction, and to spot where the implementation drifted from the business.
Q · How did you find the boundaries here?
By watching where the actor, language, model, and vendor all change at once. Payments is the clearest: a different actor (the provider), different language (capture/refund), its own model (Transaction), and its own failure mode (double-charge) all converge there.
Q · What was the most useful hotspot?
"Retry double-charges?" It connects a DDD boundary (Payments owns the Transaction) to a distributed-systems fix (idempotency via a unique payment_id). One sticky, two courses' worth of lessons.
Event-storm forwards to design a system; event-storm backwards to understand one. Run on ClubCP's real code, the workshop turned a wall of routes and Sequelize models into three clean contexts and a fourth underneath, with every boundary backed by a hotspot you can point at in the source. Same notation as the primer, real stakes.
Companion to Event Storming (the primer), S8 · Domain-Driven Design, and P3 · ADR #2. The app: clubcp.app. Further reading: EventStorming.com (Alberto Brandolini) · Fowler · Bounded Context.