Reference · gazar.dev ← From Senior to Staff

Case study · event storming a real codebase

Event storming ClubCP, one sticky at a time.

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.

00

The colour grammar of the wall

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.

Ticket Reserved

Domain event orange

Something that happened, in past tense. The spine of the board.

Register for Event

Command blue

An intention that causes an event. Maps to an API route.

TicketUser

Aggregate yellow

The model that enforces the rules and emits the event (a Sequelize model).

Member

Actor small yellow

A role that issues a command. The who.

Stripe / PayPal

External system pink

A third party ClubCP calls or that calls back (payments, email, SES).

whenever… then…

Policy lilac

A reactive rule, mostly the background jobs & schedulers. The glue.

Event detail

Read model green

The view an actor reads to decide their next command.

HOTSPOT

Hotspot pink, rotated

A real risk or sharp decision in the code. Park it loudly.

The grammar in one line

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.

The app we'll storm: ClubCP

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.

1

Read the routes & models · in silence

Chaotic exploration: every event the app actually emits

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

Club Created
Event Published
Ticket Reserved
Membership Purchased
Added to Waiting List
Payment Captured
Attendee Checked In
Broadcast Sent
Email Bounced
No-Show Warning Sent
Ticket Approval Granted
Feedback Submitted

What the dump already tells us

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.

2

Order the spine · the member's journey

Enforce the timeline: a member attends a paid event

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

Event Published
Ticket Reserved
Payment Authorized
Payment Captured
Attendee Checked In
No-Show Warning Sent
event goes liveafter the event
3

Mark the pain · pink diamonds

Hotspots and pivotal events, straight from the code

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

Event Published
Auto-publish a past-dated event?
Ticket Reserved
Sold out → waitlist? Needs approval?
Payment Authorized
PayPal or Stripe? Per club
Payment Captured
Retry double-charges? Blocked member?
Attendee Checked In
No-Show Warning Sent
Double-send on two job runs? Bounced inbox?
event goes liveafter the event

These are not hypothetical, they're in the code

  • Retry double-charges? Solved by a unique payment_id on a DONE transaction (migration unique-payment-id-when-done), so a replayed capture is idempotent, the S6 idempotency lesson, shipped.
  • Sold out → waitlist? 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.
  • Double-send no-show emails? sendNoShowEmails.js atomically claims the event (no_show_emails_sent: false → true in one UPDATE) so two concurrent job runs can't both send.
  • Blocked member paid anyway? executePaymentV2 re-checks the club block-list at capture time, even after the provider says "paid".
4

Add the who & the vendors

Actors and external systems

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

Event Editor
Event Published
Member
Ticket Reserved
Member
PayPal / Stripe
Payment Authorized
PayPal / Stripe
Payment Captured
Door Staff
Attendee Checked In
Scheduler
SendInBlue · AWS SES
No-Show Warning Sent
event goes liveafter the event
5

Add the intentions · blue

Commands: the API routes behind each event

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

Event Editor
Publish Event
Event Published
Member
Register for Event
Ticket Reserved
PayPal / Stripe
Start Ticket Payment
Payment Authorized
PayPal / Stripe
Execute Payment
Payment Captured
Door Staff
Check In (QR)
Attendee Checked In
Scheduler
Run No-Show Scan
No-Show Warning Sent
event goes liveafter the event

Real routes: POST /ticket/event/:eventId/register, POST /payment/sendTicketV2POST /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).

6

Add the models · big yellow

Aggregates: the Sequelize models that hold the rules

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

Event Editor
Publish Event
Event
Event Published
Member
Register for Event
Ticket · TicketUser
Ticket Reserved
PayPal / Stripe
Start Ticket Payment
Transaction
Payment Authorized
PayPal / Stripe
Execute Payment
Transaction
Payment Captured
Door Staff
Check In (QR)
TicketUser
Attendee Checked In
Scheduler
Run No-Show Scan
Event · EmailBatch
No-Show Warning Sent
event goes liveafter the event

The clusters, and the invariants behind them

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.

7

Add the glue · lilac

Policies: ClubCP's background jobs and schedulers

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)

Publish Event
Event
Event Published
whenever auto_publish_at reached → Publish (60s scheduler)
Register for Event
Ticket · TicketUser
Ticket Reserved
whenever capacity full & waitlist on → status WAITING
Start Ticket Payment
Transaction
Payment Authorized
Execute Payment
Transaction
Payment Captured
whenever Captured → activate TicketUser + queue confirmation
Check In (QR)
TicketUser
Attendee Checked In
Run No-Show Scan
Event · EmailBatch
No-Show Warning Sent
whenever ended & not checked-in → queue no-show email
event goes liveafter the event
8

Add the views · green

Read models: what each actor looks at to decide

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)

Draft / planned events
Event Editor
Publish Event
Event Published
Event detail · seats left
Member
Register for Event
Ticket Reserved
Hosted checkout
Member
Start Ticket Payment
Payment Authorized
PayPal / Stripe
Execute Payment
Payment Captured
QR scanner · attendee list
Door Staff
Check In (QR)
Attendee Checked In
Email batch dashboard
Club Admin
Run No-Show Scan
No-Show Warning Sent
event goes liveafter the event

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.

9

Trace the clusters

The payoff: ClubCP's bounded contexts emerge

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

Publish Event
Event
Event Published
Register for Event
Ticket · TicketUser
Ticket Reserved
Start Ticket Payment
Transaction
Payment Authorized
Execute Payment
Transaction
Payment Captured
Check In (QR)
TicketUser
Attendee Checked In
Run No-Show Scan
Event · EmailBatch
No-Show Warning Sent

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

event goes liveafter the event

The lesson only a real app could teach

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.

From the wall to ClubCP's architecture

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 contextOwns (models)Emits (events)Reacts to (policy / job)
Events & TicketingEvent, Ticket, TicketUser, TicketTemplate, EventTicketAlertEvent Published, Ticket Reserved, Added to Waiting List, Approval Granted, Attendee Checked Inauto-publish at auto_publish_at · capacity → waitlist · no-show scan claims the event
PaymentsTransaction (+ Club's provider config)Payment Authorized, Payment Captured, Payment Failedon Captured → activate TicketUser/Membership + queue receipt · re-check block-list
CommunicationsEmailBatch, EmailDelivery, SuppressedEmail, Conversation, Message, BroadcastEmail Sent, Email Bounced, Email Suppressed, Broadcast SentBullMQ priority queue · SES bounce/complaint webhook → suppress · batch completion
Club & MembershipClub, Membership, MembershipPlan, User, ClubBlock, InboxAccessClub Created, Membership Purchased, Member Role Changed, User Blockedmembership-expiry reminders · provides payment config & block-list to Payments

How this connects to S8 and Project 3

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).

!

Lessons from storming a real codebase, and the Q&A

What the exercise surfaced in ClubCP

  • The async jobs are the real architecture. Auto-publish, no-show, and email all live in schedulers/BullMQ, the lilac policies, not in the request path. Miss the jobs and you miss half the system.
  • Money and capacity are separate aggregates, so paid registration is a tiny saga (DRAFT → capture → ACTIVE), not one transaction. The code already reflects this; the wall makes it obvious.
  • Communications is a context, not a util. Suppression lists, priority tiers, bounce webhooks and batch dashboards are a domain of their own that everything else merely triggers.
  • One repo, several businesses. Membership, messaging/broadcast, and analytics each fell off the bounded slice, each a candidate context the next storm would map.

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.

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.