Skip to main content

Event Storming Workshops

Unlock domain knowledge by visualizing business events and their causal relationships.

TL;DR

Event storming is a collaborative workshop technique where domain experts, product managers, and engineers visualize business processes as sequences of events. Events are immutable facts (OrderCreated, PaymentProcessed, ShipmentConfirmed). Participants brainstorm events, arrange them chronologically, identify actors and commands, spot inconsistencies, and agree on bounded contexts. It's not a UML diagram—it's a conversation tool. Maximum 2-hour sessions with 5-10 participants. The output: shared domain understanding, identified subdomains, clear event flows, and reduced miscommunication.

Learning Objectives

  • Understand what events are in DDD and why they matter
  • Run event storming workshops effectively
  • Identify actors, commands, aggregates, and bounded contexts
  • Design event-driven architectures from domain knowledge
  • Translate workshop outputs into code (event handlers, sagas)
  • Avoid common pitfalls (wrong participants, too many events)
  • Scale event storming to large domains
  • Bridge communication gap between business and engineering

Motivating Scenario

A checkout flow. Business says "after payment, we ship immediately." Engineering says "we need 2 days for fraud checks." Product doesn't know the delay exists. During event storming, you discover the fraud check is a parallel process that can run while shipment is prepared. You visualize: PaymentProcessed → FraudCheckStarted (parallel) → ShipmentPrepared → FraudCheckCompleted → ShipmentConfirmed. Now everyone understands the process. Without event storming, these misunderstandings live in email threads and get baked into code.

Core Concepts

What is an Event?

An event is an immutable fact about something that happened in the domain. Written in past tense.

PaymentProcessed
OrderShipped
RefundInitiated
InventoryReserved
CustomerBlocked

Events flow through time. They're not requests; they're records of what occurred.

Types of Events in Event Storming

TypeDefinitionExample
Domain EventBusiness-critical event triggering processesOrderCreated, PaymentFailed
Integration EventEvent crossing system boundariesUserRegistered (sent to email service)
Policy EventTriggered by business rules (commands)WarehouseNotified (when OrderCreated)
Hotspot EventUncertain/complex event needing clarificationFraudDetected (unclear how to handle)

Workshop Phases

  1. Chaotic Exploration (30 min): Dump all events on the wall. No order, no organization. Unconstrained brainstorm.
  2. Event Sequencing (40 min): Arrange events on a timeline. Identify gaps, redundancy, confusion.
  3. Actor Identification (30 min): Who triggers events? Users? Systems? Scheduled jobs?
  4. Command Mapping (20 min): What commands cause events? CreateOrder → OrderCreated.
  5. Aggregate Identification (20 min): Which events belong together? Order aggregate: OrderCreated, OrderConfirmed, OrderShipped.
  6. Bounded Context Definition (30 min): Where do boundaries exist? Separate contexts: Orders, Payments, Shipping.

Total time: 2-3 hours, ideally with breaks.

Practical Workshop Example: E-Commerce Domain

Step 1: Chaotic Exploration

Dump events on index cards or sticky notes:

CustomerRegistered
CartCreated
CartItemAdded
CartItemRemoved
CheckoutStarted
PaymentAuthorized
PaymentProcessed
OrderCreated
FraudCheckStarted
FraudCheckPassed
FraudCheckFailed
InventoryReserved
InventoryReservationFailed
ShipmentPrepared
ShipmentShipped
DeliveryArrived
DeliveryFailed
RefundInitiated
RefundProcessed
OrderCancelled
CustomerContacted

Step 2: Event Sequencing & Timeline

Arrange events chronologically on a physical or digital timeline:

Timeline:
─────────────────────────────────────────────────────────────

Customer Cart Checkout Payment Fulfillment Delivery
───────── ───── ──────────── ────── ──────────── ────────

│ │ │ │ │ │
├─ CustomerRegistered │ │ │ │
│ │ │ │ │ │
│ ├─ CartCreated │ │ │ │
│ │ │ │ │ │
│ ├─ CartItemAdded│ │ │ │
│ │ (multiple) │ │ │ │
│ │ │ │ │ │
│ │ ├─ CheckoutStarted│ │ │
│ │ │ │ │ │
│ │ │ ├─ PaymentAuthorized│ │
│ │ │ │ │ │
│ │ │ ├─ FraudCheckStarted│ │
│ │ │ │ │ │
│ │ │ ├─ FraudCheckPassed│ │
│ │ │ │ │ │
│ │ │ ├─ PaymentProcessed│─┐ │
│ │ │ │ │ │ │ │
│ │ │ │ │ ├─ InventoryReserved
│ │ │ │ │ │ │ │
│ │ │ │ │ ├─ ShipmentPrepared
│ │ │ │ │ │ │ │
│ │ │ │ │ ├─ ShipmentShipped─┐
│ │ │ │ │ │
│ │ │ │ │ ├─ DeliveryArrived
│ │ │ │ │ │
│ │ │ ├─ RefundInitiated (if needed) │
│ │ │ │ │
│ │ │ └─ RefundProcessed │
│ │ │ │
│ │ └─ OrderCancelled (at any time before ship) │

Step 3: Identify Actors (Who & What)

ActorTriggersEvents Produced
CustomerRegisters, adds items to cart, initiates checkoutCustomerRegistered, CartItemAdded, CheckoutStarted
Checkout ServiceValidates and initiates paymentPaymentAuthorized
Payment ServiceProcesses paymentPaymentProcessed, PaymentFailed
Fraud ServiceRuns fraud checks asynchronouslyFraudCheckPassed, FraudCheckFailed
Fulfillment ServiceReserves inventory, prepares shipmentInventoryReserved, ShipmentPrepared, ShipmentShipped
Delivery ServiceTracks deliveryDeliveryArrived, DeliveryFailed

Step 4: Commands (What Actions Trigger Events?)

Commands (User/System Actions) → Domain Events
─────────────────────────────────────────────

RegisterCustomer() → CustomerRegistered
CreateCart() → CartCreated
AddItemToCart(item) → CartItemAdded
InitiateCheckout() → CheckoutStarted
AuthorizePayment(amount) → PaymentAuthorized
ProcessPayment(amount) → PaymentProcessed | PaymentFailed
CheckFraud(order) → FraudCheckStarted
ReserveInventory(items) → InventoryReserved | InventoryReservationFailed
PrepareShipment(order) → ShipmentPrepared
ShipOrder(order) → ShipmentShipped
InitiateRefund(order) → RefundInitiated
ProcessRefund(order) → RefundProcessed
CancelOrder(order) → OrderCancelled

Step 5: Identify Aggregates

Aggregates group related events. An aggregate is the consistency boundary.

Order Aggregate:
- OrderCreated
- CartItemAdded (until checkout)
- CheckoutStarted
- PaymentProcessed
- OrderConfirmed (or OrderFailed)

Payment Aggregate:
- PaymentAuthorized
- PaymentProcessed
- PaymentFailed
- RefundProcessed

Shipment Aggregate:
- InventoryReserved
- ShipmentPrepared
- ShipmentShipped
- DeliveryArrived

FraudCheck Aggregate:
- FraudCheckStarted
- FraudCheckPassed
- FraudCheckFailed

Step 6: Identify Bounded Contexts & Subdomains

┌─────────────────────────────────────────────────────────┐
│ E-Commerce Domain │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────┐ ┌────────────────────────┐ │
│ │ Ordering Context │ │ Payment Context │ │
│ │ ───────────────── │ │ ────────────────── │ │
│ │ - OrderCreated │ │ - PaymentAuthorized │ │
│ │ - OrderConfirmed │ │ - PaymentProcessed │ │
│ │ - OrderCancelled │ │ - PaymentFailed │ │
│ │ - ItemAdded │ │ - RefundProcessed │ │
│ └──────────────────────┘ └────────────────────────┘ │
│ │ │ │
│ │ subscribes to │ publishes events │
│ │ PaymentProcessed │ │
│ │ │ │
│ ┌──────────────────────┐ ┌────────────────────────┐ │
│ │ Fulfillment Context │ │ Fraud Context │ │
│ │ ────────────────── │ │ ────────────────── │ │
│ │ - InventoryReserved │ │ - FraudCheckStarted │ │
│ │ - ShipmentPrepared │ │ - FraudCheckPassed │ │
│ │ - ShipmentShipped │ │ - FraudCheckFailed │ │
│ │ - DeliveryArrived │ │ │ │
│ └──────────────────────┘ └────────────────────────┘ │
│ │ │ │
│ │ subscribes to │ subscribes to │
│ │ OrderConfirmed │ OrderCreated │
│ │ (after fraud check) │ │
│ │ │ │
└─────────────────────────────────────────────────────────┘

Code Examples: From Workshop to Implementation

from dataclasses import dataclass
from datetime import datetime
from typing import List, Optional
from enum import Enum

# Events from workshop
@dataclass(frozen=True)
class OrderCreated:
"""Immutable event from domain"""
order_id: str
customer_id: str
items: List[dict]
total_amount: float
created_at: datetime

@dataclass(frozen=True)
class PaymentProcessed:
order_id: str
amount: float
transaction_id: str
processed_at: datetime

@dataclass(frozen=True)
class FraudCheckStarted:
order_id: str
amount: float
customer_id: str
started_at: datetime

@dataclass(frozen=True)
class FraudCheckPassed:
order_id: str
passed_at: datetime

@dataclass(frozen=True)
class FraudCheckFailed:
order_id: str
reason: str
failed_at: datetime

@dataclass(frozen=True)
class ShipmentPrepared:
order_id: str
warehouse_id: str
prepared_at: datetime

@dataclass(frozen=True)
class ShipmentShipped:
order_id: str
tracking_number: str
shipped_at: datetime

# Event store - captures all events
class EventStore:
def __init__(self):
self.events: List = []
self.subscribers: dict = {}

def append(self, event):
"""Append event to immutable log"""
self.events.append(event)
self._notify_subscribers(event)

def subscribe(self, event_type, handler):
"""Subscribe to events"""
if event_type not in self.subscribers:
self.subscribers[event_type] = []
self.subscribers[event_type].append(handler)

def _notify_subscribers(self, event):
"""Notify all handlers for this event type"""
event_type = type(event)
if event_type in self.subscribers:
for handler in self.subscribers[event_type]:
handler(event)

def get_events_for_aggregate(self, aggregate_id):
"""Retrieve all events for an aggregate"""
return [e for e in self.events if hasattr(e, 'order_id') and e.order_id == aggregate_id]

# Aggregates (from workshop step 5)
class OrderAggregate:
"""Order aggregate - consistency boundary"""
def __init__(self, order_id):
self.order_id = order_id
self.events = []
self.status = "pending"
self.items = []
self.amount = 0.0

def create_order(self, customer_id, items, total_amount):
"""Command that creates domain event"""
event = OrderCreated(
order_id=self.order_id,
customer_id=customer_id,
items=items,
total_amount=total_amount,
created_at=datetime.now()
)
self.events.append(event)
self.status = "created"
return event

def confirm_order(self):
"""Can only confirm after fraud check passes"""
event = OrderConfirmed(order_id=self.order_id, confirmed_at=datetime.now())
self.events.append(event)
self.status = "confirmed"
return event

@dataclass(frozen=True)
class OrderConfirmed:
order_id: str
confirmed_at: datetime

# Bounded context: Fraud domain
class FraudCheckService:
"""Fraud context - independent consistency boundary"""
def __init__(self, event_store):
self.event_store = event_store
self.pending_checks = {}

# Subscribe to relevant events from other contexts
event_store.subscribe(OrderCreated, self.on_order_created)

def on_order_created(self, event: OrderCreated):
"""React to OrderCreated event from Ordering context"""
print(f"Fraud: Checking order {event.order_id}")

fraud_event = FraudCheckStarted(
order_id=event.order_id,
amount=event.total_amount,
customer_id=event.customer_id,
started_at=datetime.now()
)

self.event_store.append(fraud_event)
self.pending_checks[event.order_id] = fraud_event

# Simulate fraud check logic
if event.total_amount > 10000:
# High-value orders need manual review
result = FraudCheckFailed(
order_id=event.order_id,
reason="manual_review_required",
failed_at=datetime.now()
)
else:
# Low-value orders auto-pass
result = FraudCheckPassed(
order_id=event.order_id,
passed_at=datetime.now()
)

self.event_store.append(result)

# Bounded context: Fulfillment domain
class FulfillmentService:
"""Fulfillment context - owns shipment logic"""
def __init__(self, event_store):
self.event_store = event_store

# Subscribe to events indicating readiness to fulfill
event_store.subscribe(FraudCheckPassed, self.on_fraud_check_passed)

def on_fraud_check_passed(self, event: FraudCheckPassed):
"""React to FraudCheckPassed from Fraud context"""
print(f"Fulfillment: Preparing shipment for {event.order_id}")

# Emit shipment events
shipment_event = ShipmentPrepared(
order_id=event.order_id,
warehouse_id="warehouse-001",
prepared_at=datetime.now()
)

self.event_store.append(shipment_event)

# Workshop output: Saga (cross-context orchestration)
class OrderFulfillmentSaga:
"""
Saga coordinates multi-context process:
Order Created → Fraud Check → Shipment Preparation → Shipment
"""
def __init__(self, event_store):
self.event_store = event_store
event_store.subscribe(OrderConfirmed, self.on_order_confirmed)
event_store.subscribe(FraudCheckFailed, self.on_fraud_check_failed)

def on_order_confirmed(self, event: OrderConfirmed):
"""Compensating transaction: order is ready to fulfill"""
print(f"Saga: Order {event.order_id} confirmed, initiating fulfillment")
# Orchestrate fulfillment process

def on_fraud_check_failed(self, event: FraudCheckFailed):
"""Compensating transaction: cancel order"""
print(f"Saga: Order {event.order_id} failed fraud check ({event.reason}), cancelling")
# Emit OrderCancelled event

# Example usage
event_store = EventStore()
fraud_service = FraudCheckService(event_store)
fulfillment_service = FulfillmentService(event_store)
saga = OrderFulfillmentSaga(event_store)

# Simulate workflow
order = OrderAggregate("order-123")
order_created = order.create_order("cust-456", [{"sku": "item-1", "qty": 2}], 5000.0)
event_store.append(order_created)

# Events cascade through contexts automatically
print("\nEvents in store:")
for event in event_store.events:
print(f" - {type(event).__name__}: {event}")

Real-World Examples

Case Study: Payment Processing Domain

A fintech company held an event storming workshop for payment processing. Initial assumption: "payment happens, then we confirm." Reality: 7 sequential processes (KYC, fraud, reserve funds, process, settlement, reconciliation, audit logging). Without event storming, engineers built a linear system; payment failures left the system in inconsistent states. Event storming revealed: each process is independent; failures in one don't block others. They redesigned to emit events: PaymentAuthorized, KYCPassed, FraudCheckPassed, etc. Failures became recoverable via event replay.

Case Study: Microservices Boundaries

A retail company had 8 engineers but 15 microservices with unclear boundaries. Event storming revealed 3 core bounded contexts: Orders, Inventory, Shipping. 8 microservices were consolidated to 3. Communication patterns became clear: Orders emits OrderConfirmed → Inventory subscribed. No more direct service-to-service calls.

Common Mistakes and Pitfalls

Mistake 1: Wrong Participants

❌ Only engineers attend
- Misses business logic
- Requirements hidden in email
- Implementation is guesswork

✅ Include:
- Product/Business (knows domain logic)
- Customer support (knows edge cases)
- Engineers (knows constraints)
- 1 facilitator (drives conversation)

Mistake 2: Too Many Events or Commands

❌ WRONG: Mixing too many domains
- CustomerLiked (user engagement)
- ProductViewed (analytics)
- CartAbandoned (marketing)
- PaymentProcessed (payment)
- [20 more...]
Result: Unfocused, no clear bounded contexts

✅ CORRECT: Separate workshops per domain
- First workshop: Order domain only
- Second workshop: Payment domain only
- Third workshop: Shipping domain only

Mistake 3: Treating Events as Commands

❌ WRONG: "Execute PaymentProcessed" - events aren't commands
Events are immutable facts. You don't execute them.

✅ CORRECT:
Command: ProcessPayment()
Event: PaymentProcessed

Commands are imperative (do this)
Events are declarative (this happened)

Mistake 4: No Bounded Contexts

❌ WRONG: All events in one model
- No clear ownership
- Tight coupling
- Hard to scale teams

✅ CORRECT: Define bounded contexts from event clusters
- Order context: OrderCreated, OrderConfirmed, OrderCancelled
- Payment context: PaymentAuthorized, PaymentProcessed, RefundProcessed
- Each context owns its events
- Contexts communicate via published events

Production Considerations

Scaling Event Storming

For large domains (100+ events):

  • Split by business capability: Orders, Payments, Shipping, etc.
  • Multiple 2-hour workshops: One per subdomain.
  • Hierarchy of models: Big picture model, then detailed models per context.
  • Documentation: Translate workshop outputs to domain language specification (DLS).

Translating to Code

After workshop, bridge workshop to codebase:

  1. Create event classes: OrderCreated, PaymentProcessed, etc.
  2. Build aggregates: Order aggregate handles OrderCreated, OrderConfirmed.
  3. Implement event store: Persist all events immutably.
  4. Create event handlers: FraudService subscribes to OrderCreated.
  5. Build sagas: Orchestrate cross-context processes.

Maintaining Domain Knowledge

  • Workshops are point-in-time: Revisit annually or when major changes occur.
  • Document decisions: Why did you choose this bounded context?
  • Onboard with events: New engineers learn domain from event sequences.

Self-Check

  • What is an event vs. a command?
  • Why are bounded contexts important?
  • What should you do if participants disagree on event flow?
  • How do you handle events that could happen out of order?
  • When should you split an event storming session?

Design Review Checklist

  • All business events identified?
  • Commands mapped to events?
  • Aggregates grouped correctly?
  • Bounded contexts defined?
  • Event sequences form valid timeline?
  • No ordering dependencies between unrelated events?
  • Integration points identified?
  • Compensation flows for failures?
  • Hotspots resolved?
  • Participants agree on model?
  • Output documented (diagram, text)?
  • Code structure matches bounded contexts?

Next Steps

  1. Schedule workshop — 2-3 hours, 5-10 participants
  2. Prepare materials — Index cards, whiteboard/digital board
  3. Run session — Explore → Sequence → Map → Identify
  4. Document model — Event flow diagram, bounded contexts
  5. Implement sagas — Cross-context orchestration
  6. Create event handlers — Subscribe to domain events

References