Why Domain Storytelling beats EventStorming when the team and domain experts haven't agreed on language yet, with a workshop walkthrough and TypeScript code.
We were three coffees in and still arguing about what “active member” meant. The studio manager kept saying members. The engineer next to me kept saying users. Marketing called them subscribers. EventStorming had us stuck in a swamp of orange and yellow stickies and nobody had moved a piece in twenty minutes.
I wiped the whiteboard, drew a stick figure, and asked her to tell me what happens when someone walks in for a class on a Tuesday evening.
This was at the digital product agency I led engineering at, deep into a portfolio-wide migration to DDD across a lot of legacy client products. Boutique fitness was one of them. We had a small army of engineers trying to retrofit bounded contexts onto products that had been built by three different teams over five years. EventStorming kept stalling exactly like this. People who had never sat in the same room before were being asked to converge on language they hadn’t even agreed existed.
Domain Storytelling fixed it in about an hour.
EventStorming is good when the team already shares vocabulary. You stand at a wall, throw orange stickies for domain events, layer commands and policies on top, and the conversation moves because everyone already knows what an “order placed” event is supposed to mean.
When the language isn’t settled yet, that wall turns into a Rorschach test. The studio manager sees “MemberCheckedIn” and reads it as the moment someone walks through the door. The engineer sees the same sticky and reads it as the database write. The marketing person doesn’t know what either of those people mean and is just nodding politely.
I’ve been in this exact swamp on at least four client projects from that agency portfolio. The pattern was always the same. Whiteboard fills up, conversation slows, the room gets quiet, somebody suggests we “come back to this after lunch.” We never came back to it after lunch.
Domain Storytelling is dumb in the way good DDD tools are dumb. You give the domain expert four pictograms and a numbered sequence and you ask them to tell you a story. Actor on the left. Work object in the middle. Activity arrow connecting them. A number next to each arrow showing the order. That’s it.
We used Egon.io to capture it live on a projector while the studio manager talked. By the end of an hour we had three stories: “member checks in to a class”, “trainer cancels a session”, “member buys a multi-class pack.” The studio manager corrected our nouns and verbs in real time as we drew. By story three she was reaching for the laptop herself.
Egon.io exports each story to a DST JSON file. We committed those next to the architecture decision records:
{
"version": "3.0.0",
"domain": "boutique-fitness",
"scope": "member-checkin-v1",
"actors": [
{ "id": "a1", "type": "person", "name": "Member" },
{ "id": "a2", "type": "person", "name": "StudioManager" },
{ "id": "a3", "type": "system", "name": "ClassRoster" }
],
"workObjects": [
{ "id": "w1", "type": "document", "name": "ClassBooking" },
{ "id": "w2", "type": "document", "name": "AttendanceRecord" }
],
"activities": [
{ "seq": 1, "from": "a1", "to": "w1", "verb": "presents" },
{ "seq": 2, "from": "a2", "to": "w1", "verb": "validates" },
{ "seq": 3, "from": "a2", "to": "w2", "verb": "marks attended" },
{ "seq": 4, "from": "w2", "to": "a3", "verb": "syncs to" }
],
"annotations": [
{ "on": "seq:2", "note": "Booking must be within 15 min of class start" },
{ "on": "seq:3", "note": "Cannot mark attended if cancelled or refunded" }
]
}
Once a story is captured, the JSON file is the thing the team argues about. Not a Miro board that nobody opens again. Not a Google Doc with seventeen revisions. A file in the repo, next to the code it describes.
Here’s where storytelling pays back the time. Clusters of stories that share the same actors and work objects are bounded contexts in disguise. Stories that cross actor groups, or where a work object changes meaning depending on who’s holding it, mark the seams between contexts.
In the fitness product, three story clusters fell out:
The “Member” actor showed up in all three but meant slightly different things in each. In Class Scheduling, a Member is whoever has a valid booking. In Billing, a Member is whoever has an active subscription. In Coaching, a Member is whoever shares a trainer assignment.
That’s not a bug. That’s three different bounded contexts each holding their own definition of Member, which is exactly what DDD says they should do. The TypeScript layout we landed on:
// src/contexts/class-scheduling/domain/class-booking.ts
import { AggregateRoot } from '@/shared/domain/aggregate-root'
import { DomainEvent } from '@/shared/domain/domain-event'
import { ClassBookingCancelled } from './events/class-booking-cancelled'
import { MemberCheckedIn } from './events/member-checked-in'
export class ClassBooking extends AggregateRoot {
private constructor(
public readonly id: string,
public readonly memberId: string,
public readonly classId: string,
public readonly startsAt: Date,
private status: 'reserved' | 'attended' | 'cancelled',
private readonly capacity: number,
private readonly currentAttendees: number,
) { super() }
static reserve(input: {
id: string
memberId: string
classId: string
startsAt: Date
capacity: number
currentAttendees: number
}): ClassBooking {
if (input.currentAttendees >= input.capacity) {
throw new Error('ClassFullError')
}
return new ClassBooking(
input.id,
input.memberId,
input.classId,
input.startsAt,
'reserved',
input.capacity,
input.currentAttendees + 1,
)
}
checkIn(at: Date): void {
if (this.status !== 'reserved') {
throw new Error('OnlyReservedBookingsCanCheckIn')
}
const minutesEarly = (this.startsAt.getTime() - at.getTime()) / 60000
if (minutesEarly > 15) {
throw new Error('TooEarlyToCheckIn')
}
this.status = 'attended'
this.record(new MemberCheckedIn(this.id, this.memberId, at))
}
cancel(at: Date, reason: string): void {
if (this.status === 'attended') {
throw new Error('CannotCancelAttendedBooking')
}
const hoursToClass = (this.startsAt.getTime() - at.getTime()) / 3_600_000
if (hoursToClass < 4) {
throw new Error('CancellationWindowClosed')
}
this.status = 'cancelled'
this.record(new ClassBookingCancelled(this.id, this.memberId, reason, at))
}
}
Notice what’s anchored where. The check-in window of 15 minutes lives as an invariant on the aggregate because the studio manager said it during the story. The 4-hour cancellation rule came from the same conversation. Those weren’t engineering decisions. They were facts about the business that we captured on a whiteboard, then encoded.
The work object that gets mutated across a story is your aggregate root candidate. That’s the bit that survived the most arguments in my agency days. People used to argue about aggregate boundaries for whole afternoons. After we adopted storytelling, those arguments turned into ten-minute conversations because the answer was already on the wall.
The “must be true” clauses the domain expert says out loud, the ones we wrote as annotations on the activity arrows, become invariants. Annotation says “cannot mark attended if cancelled” and that becomes a guard on checkIn. Annotation says “booking must be within 15 minutes” and that becomes the time check. Each invariant trails back to a story step that an actual human voiced.
Different gig. The combat-sports tournament platform I CTO’d in London. We had a public rankings page used by athletes, federations, and the press. PostgreSQL was the system of record.
We sat down with a federation rep and a senior athlete and did one Domain Storytelling workshop on what “ranking” meant. Turned out we had been treating “tournament standing” and “global ranking” as the same work object for years. They were two different things, calculated differently, with different update cadences and different sources of truth. Once we drew the stories and watched the actor groups split, the aggregates wanted to split too. Tournament Standings became its own bounded context. Global Rankings became another, downstream of it, with an explicit translation step.
Hours of meetings saved. Whole categories of bugs that just stopped happening.
I run Domain Storytelling first whenever the language isn’t settled. EventStorming after, once nouns and verbs are agreed, to walk through behavior in detail. They’re complementary, not competing. The “always start with EventStorming” advice you see in most DDD writeups assumes the team already shares vocabulary. Most teams I’ve worked with don’t. Especially in agency work, where the engineers and the domain experts have never been in the same room before.
We commit every captured story next to the ADR that references it:
# ADR-0023: Split Standings from Global Rankings
## Status
Accepted
## Context
Storytelling session with federation rep and senior athlete revealed that
"tournament standing" and "global ranking" are two distinct work objects,
held by different actors, updated on different cadences. Captured stories:
- domain/stories/tournament-standing-update.dst.json
- domain/stories/global-ranking-recompute.dst.json
## Decision
Split into two bounded contexts. Standings owns the within-tournament view.
Rankings owns the cross-tournament aggregate, downstream of Standings via
an explicit translation layer.
## Consequences
- Two aggregates with separate invariants and freshness contracts.
- Anti-corruption layer at the Rankings boundary to translate Standing events.
- Derived index gets its own freshness metric, monitored independently.
Worth saying out loud, because I get asked this a lot: storytelling is not a replacement for the rest of DDD. You still need tactical patterns, you still need bounded contexts, you still need the boring discipline of ubiquitous language. Storytelling is the on-ramp. It gets the room to the point where the rest of DDD starts to bite.
Thanks for reading. If you’ve got thoughts, send them my way.