What event storming tells you about service boundaries
Event storming is a workshop format, created by Alberto Brandolini, in which developers and domain experts map a business process as a timeline of domain events, written in the past tense on sticky notes ("Order Placed", "Payment Captured"). A bounded context is the scope within which a model and its vocabulary stay consistent. Inside it, "customer" or "shipment" means exactly one thing.
The wall does not give you a service diagram. It gives you evidence of three kinds:
- Language: places where the same word changes meaning, or two words describe one thing.
- Ownership: which team or role triggers, decides on and cares about each event.
- Consistency rules: which invariants must hold immediately and which can tolerate delay.
Service boundaries follow from that evidence. Drawing boxes directly from clusters of notes skips the reasoning and tends to produce services that are split by noun instead of by responsibility.
| Level | Question it answers | Typical output |
|---|---|---|
| Big picture | Where does the language or ownership shift across the whole business flow? | Candidate bounded context boundaries, hotspots |
| Process modeling | How does one process move from trigger to outcome, including policies and read models? | A validated flow with its decision points |
| Design level | Which commands must be rejected to protect an invariant? | Aggregates and their consistency boundaries |
Big picture finds the candidate contexts. Design level drives aggregate design inside a single context. Mixing the two levels in one session blurs both results.
The format is most useful to backend and platform teams working in a large or poorly understood domain, or decomposing a monolith whose module boundaries no longer match how the business actually works.
How to run the session: people, notation and timeline
Invite the people who know how the business works: domain experts from operations, finance, support or sales, plus the engineers who will build the services. If only engineers attend, the wall fills with table names and status columns, and you end up modeling the current schema instead of the domain. Domain experts bring the vocabulary, the exceptions and the handoffs that the code hides. A facilitator keeps the discussion moving and stops it from turning into solution design.
The notation is deliberately small:
| Element | What it captures | Example |
|---|---|---|
| Domain event | Something that happened, in past tense | Order Placed |
| Command | An intent that causes an event | Place Order |
| Actor | Who issues the command | Customer |
| Policy | A reaction: "whenever X, then Y" | When payment fails, notify customer |
| Read model | Data an actor needs to decide | Cart summary |
| External system | Something outside your control | Payment provider |
| Hotspot | A question, conflict or pain point | "Who approves refunds?" |
Start with events only, placed left to right in chronological order. Do not group anything yet. Building the timeline first shows where language changes, where one team hands off to another, and where people disagree about what happens. Those are the signals the next section uses. Grouping early locks in assumptions before they are visible.
Sessions are expensive. To estimate the cost, multiply the number of participants by the session length in hours. Illustrative estimate (assumed numbers, not a benchmark): 8 participants × 4 hours = 32 person-hours. The results also go stale as the business changes. Scope each session to one business flow and repeat it when that area changes.
Reading the wall: heuristics for bounded context boundaries
Once the timeline is on the wall, boundaries show up as places where the language, the people or the timing change. No single signal is decisive. Look for several of them landing in the same spot.
- Same word, different meaning. In sales, an "order" is a negotiable quote with prices and discounts. In fulfillment, it is a pick list with weights and a warehouse location. If you force one model to serve both, every change in one area ripples into the other. Draw the line where the definition shifts.
- Handoffs between teams. A clean handoff, such as payment confirmed going to the warehouse, marks a point where responsibility changes owners. That is a natural seam.
- Pivotal events. Events like Order Placed or Shipment Dispatched mark phase changes. The rules that apply before such an event rarely apply after it.
- Different actors. When one cluster is driven by customers and the next by warehouse staff, the two areas have different reasons to change.
- Asynchronous policies. "Whenever X happens, we do Y" written against another area's event is a strong hint that the two sides can be decoupled.
Treat hotspots and disagreements as outputs, not noise. They mark the riskiest boundary decisions. Settle them with the business before writing code, because a wrong boundary is expensive to move once data has been split across services.
Two cautions. First, do not freeze today's org chart or process into the boundaries. Reorganizations happen, and the services should outlive them. Second, the method underweights read needs from reporting and analytics. Cross-context reports later push teams toward shared databases, so plan for that read side early.
SALES CONTEXT FULFILLMENT CONTEXT
actor: Customer actor: Warehouse staff
+--------------------------+ +--------------------------+
| "Order" = quote with | | "Order" = pick list with |
| prices and discounts | | weights, bin location |
| | pivotal event | |
| [Cart Updated] | + team handoff| POLICY (async): |
| [Discount Applied] | | "Whenever Payment |
| [Order Placed] | | Confirmed, reserve |
| [Payment Confirmed] -----+---------------->| stock and start pick" |
| | !! HOTSPOT !! | |
+--------------------------+ | [Items Picked] |
| | [Shipment Dispatched] |
| +--------------------------+
| |
v v
+---------------------------------------------------------------------+
| REPORTING / ANALYTICS READ SIDE (the wall underweights it) |
| Plan a read model fed by events, or teams drift to a shared DB |
+---------------------------------------------------------------------+
!! HOTSPOT: who owns cancellation after Payment Confirmed?
Settle with the business before coding; moving it later is costly.
Boundaries follow language and events, not today's org chart.
Several boundary signals land on the same seam: "order" changes meaning, the actor changes, and a pivotal event hands off to an asynchronous policy. The hotspot and the cross-context reporting read side are flagged as decisions to settle early.
Aggregate design inside a context
Design-level storming zooms in on one context's slice of the wall. Place each command beside the event it produces and ask two questions: what must be true before this command is accepted, and must it still be true at the instant the event is recorded? Rules where the second answer is "yes" define an aggregate. Test that answer with the domain expert. "An order may not exceed the customer's credit limit" sounds transactional, but a brief overshoot that a later check catches is often acceptable. When it is, the rule becomes a policy reacting to events, not part of the aggregate.
Keep aggregates small: one cluster of invariants, loaded and saved in a single transaction. Large aggregates cause write contention because unrelated changes compete for the same lock or version check. Reference other aggregates by ID, never by object, so nothing tempts you to modify two of them in one transaction. The example below enforces one invariant, holds another aggregate's ID, and records a domain event for others to react to.
Between aggregates and between contexts, use eventual consistency instead of distributed transactions. That choice has costs:
- Coordination: a saga or process manager to drive multi-step flows.
- Failure handling: compensating actions when a later step fails.
- Duplicate delivery: idempotent consumers, because at-least-once delivery means repeats.
- Ordering: version checks or gap tolerance for out-of-order events.
- Visible intermediate states: users and support staff will see states such as "payment pending," so design for them.
If a rule cannot tolerate that window, it stays inside one aggregate, and therefore one boundary.
type OrderId = string;
type CustomerId = string;
type OrderPlaced = {
type: "OrderPlaced";
orderId: OrderId;
customerId: CustomerId; // other contexts react using IDs only
totalCents: number;
occurredAt: string;
};
export class Order {
private lines: { sku: string; qty: number; unitPriceCents: number }[] = [];
private status: "draft" | "placed" = "draft";
private pending: OrderPlaced[] = [];
// Customer is referenced by ID, never loaded, so it cannot join this transaction.
constructor(readonly id: OrderId, readonly customerId: CustomerId) {}
addLine(sku: string, qty: number, unitPriceCents: number): void {
// Invariant that must hold at commit time: a placed order's contents are frozen.
if (this.status !== "draft") throw new Error("Order already placed");
if (qty <= 0) throw new Error("Quantity must be positive");
this.lines.push({ sku, qty, unitPriceCents });
}
place(now: Date): void {
if (this.status !== "draft") throw new Error("Order already placed");
if (this.lines.length === 0) throw new Error("Order has no lines");
this.status = "placed";
const totalCents = this.lines.reduce((s, l) => s + l.qty * l.unitPriceCents, 0);
// No credit-limit check here: a policy in another context reacts to this event
// and may trigger a compensating cancellation if the limit is exceeded.
this.pending.push({ type: "OrderPlaced", orderId: this.id, customerId: this.customerId,
totalCents, occurredAt: now.toISOString() });
}
// Repository saves the aggregate and these events in one local transaction (e.g. an outbox).
pullEvents(): OrderPlaced[] { return this.pending.splice(0); }
}
A small Order aggregate enforces the invariant that a placed order cannot change, references its customer only by ID, and records an OrderPlaced event so the credit-limit rule can run as an eventually consistent policy elsewhere.
From contexts to integration contracts and a context map
Not every event on the wall should leave its context. Internal events like CartItemQuantityAdjusted exist to keep an aggregate consistent, and their shape changes whenever the model does. Publish an event only when another context has a business reason to react to it, such as OrderPlaced or PaymentCaptured. Ask who would subscribe and what decision they make. If you cannot name one, keep the event private.
The code example that follows maps a rich internal OrderPlaced to a smaller integration event. It drops internal identifiers and pricing breakdowns, flattens nested value objects, and adds an explicit schemaVersion field. That translation layer is the contract. The internal model can then be refactored freely as long as the mapper keeps producing the same published shape.
| Pattern | Who adapts | Fits when |
|---|---|---|
| Customer/supplier | Upstream plans changes with downstream needs in mind | Teams talk regularly and the upstream will negotiate |
| Conformist | Downstream adopts the upstream model as-is | The upstream model is good enough and you have no leverage |
| Anti-corruption layer | Downstream translates at its edge | The upstream model is messy, legacy or likely to leak concepts into yours |
Publishing too much couples consumers to your internals, so every refactor becomes a cross-team migration. Publishing too little pushes consumers to call back synchronously for missing data, which brings back the runtime coupling events were meant to remove.
Versioning is ongoing work. Prefer additive changes. When you must break a contract, run old and new versions in parallel until consumers move over, then retire the old one.
// Internal event: shaped for the Ordering aggregate, free to change with the model.
interface OrderPlaced {
orderId: string;
internalCartId: string; // internal identifier, never published
customer: { id: string; tier: 'standard' | 'gold' };
lines: { sku: string; qty: number; unitPrice: number; discountRuleId?: string }[];
pricing: { subtotal: number; promoDiscount: number; tax: number; total: number };
currency: string;
placedAt: Date;
}
// Published contract: small, flat, explicitly versioned.
interface OrderPlacedV1 {
schemaVersion: 1;
orderId: string;
customerId: string;
items: { sku: string; quantity: number }[];
totalAmount: number; // only the figure consumers act on, not the breakdown
currency: string;
placedAt: string; // ISO-8601 string, not a language-specific Date
}
// The mapper is the contract boundary. Refactor OrderPlaced freely;
// this function must keep producing the same OrderPlacedV1 shape.
export function toIntegrationEvent(e: OrderPlaced): OrderPlacedV1 {
return {
schemaVersion: 1,
orderId: e.orderId,
customerId: e.customer.id, // flatten the nested value object
items: e.lines.map((l) => ({ sku: l.sku, quantity: l.qty })),
totalAmount: e.pricing.total,
currency: e.currency,
placedAt: e.placedAt.toISOString(),
};
}
A mapper turns the rich internal OrderPlaced event into a smaller, flat OrderPlacedV1 integration event. It drops internal IDs and the pricing breakdown and pins the published shape with a schemaVersion field.
Testing boundaries before you commit, and when to avoid microservices
A bounded context is a boundary in the model, where a term has one meaning and one owner. It is not automatically a deployable service. Deployment adds network failures, independent releases and distributed data, and those costs only pay off if the boundary holds under real workflows.
Test it on paper first. Take the end-to-end scenarios from the wall, such as "customer changes a delivery address after payment," and walk each one across the proposed services. For each scenario, record:
- Synchronous calls: each one couples availability and latency.
- Shared data: tables or records that more than one service reads or writes.
- Atomic steps: places where two services must change state together or not at all.
A scenario that bounces between two services several times is chatty, which usually means one concept was split in half. A step that needs an atomic update across services forces you into sagas or distributed transactions. Both are signs the boundary is misplaced. Merge the contexts or move the aggregate.
Then check the boundary against team ownership. Under Conway's law, a service that two teams must change together becomes a coordination bottleneck.
Avoid splitting at all when the system is small, when one team owns everything, or when the domain is still poorly understood. Boundaries drawn while you are still learning the domain will move, and moving them across a network is expensive.
| Option | When it fits | Main cost |
|---|---|---|
| Modular monolith | One team, boundaries still shifting | Needs discipline to keep modules from reaching into each other |
| Coarse services | A few teams, stable high-level split | Larger deployables |
| One service per context cluster | Clusters with heavy internal traffic | Clusters must be re-evaluated as the domain grows |
| Domain modeling without workshops | Experts unavailable, or a small domain | Fewer perspectives, so more blind spots |
An incremental path from workshop to code, and its cost
Make each bounded context a module inside one deployable first, and check its dependencies in the build. Other modules may call only the context's published interface. A test fails when code reaches into another context's internals or tables. A module boundary is cheap to move. It also shows whether the boundary holds under real feature work before you put a network between the pieces.
Extract the most independent context first. That is the one with the fewest inbound calls and the clearest events. On an existing monolith, use the strangler approach: route one capability to the new service, keep the old path until production traffic shows the new one behaves correctly, then delete the old code. Run a shorter event storm again when new hot spots, policies or teams appear. The boundaries you drew on day one reflect what you understood on day one.
What extra services cost
Each service adds a deployment pipeline, dashboards and alerts. It also adds network failure modes, consistency handling across separate stores, and another thing to cover on call. Merging two services later costs more than splitting a module. You have to reconcile two data stores, two consumer contracts and two release cadences.
Illustrative estimate (assumed numbers, not a benchmark). Annual overhead ≈ services × hours per service per month × 12. Assume 4 hours per month per service for upgrades, alert tuning and incident follow-up:
- Twelve fine-grained services: 12 × 4 × 12 = 576 hours per year.
- Four context-aligned services: 4 × 4 × 12 = 192 hours per year.
The workshop saves time earlier than that. Settling what "order confirmed" means takes an hour at the wall. If two teams have already built against different answers, it takes weeks.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to event storming service boundaries, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- The dependency graph answers "what does this touch?" in seconds instead of a manual audit.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- You can size a change's blast radius before merging, avoiding surprise cross-team firefighting.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.