Event Storming & DDD Modeling Conventions
Cross-checked against Brandolini's Introducing EventStorming, the "Picture That Explains Everything," and practitioner sources. Event Storming has no rigid spec — these are the de-facto community standards.
Sticky colors (the vocabulary)
| Concept | Color | Meaning |
|---|---|---|
| Domain Event | orange | Something that happened, past tense ("OrderPlaced"). The most stable convention. |
| Command | blue | Intent to change state ("PlaceOrder"). |
| Actor / User | small yellow | A person/role that issues commands. Distinct by size/shape from the aggregate. |
| Aggregate | large/pale yellow | Receives commands, enforces invariants, emits events. |
| Policy (process/reaction) | lilac / purple | "Whenever <event> then <command>" — reactive automation or human procedure. |
| Read Model / View | green | The data an actor reads before deciding. |
| External System | pink | Outside the boundary; issues commands or receives/raises events. |
| Hotspot | red | Friction, disagreement, unknowns. Human judgment — cannot come from code. |
Caveats: Actor color is the softest convention (often just a small yellow with a stick figure; treat actor vs aggregate as different shapes/sizes of yellow). Pink is sometimes also used for bounded contexts. Don't hard-assert one hex per color across legends. In a flat digital diagram you lose the size/shape cue, so give actor and aggregate genuinely different hues or they're indistinguishable.
The flow grammar ("The Picture That Explains Everything")
The canonical, self-reinforcing loop:
Actor (or Policy, or External System) --issues--> Command
Command --invoked on / handled by--> Aggregate
Aggregate --emits / raises--> Domain Event
Domain Event --triggers / reacts to--> Policy
Policy --issues--> Command (loop)
Domain Event --updates--> Read Model --read by--> Actor
External System --raises--> Domain Event (and can receive commands)
Hard rule worth encoding: aggregates only receive commands and emit events — they never issue commands. Commands come from actors, policies, or external systems. Common edge verbs: issues, invoked on / handles, emits / raises / produces, triggers / reacts to, updates, enforces (an invariant).
Policy vs Invariant vs Business Rule (the conflation to avoid)
These are different concepts — labeling all of them "Policy" is a real modeling error:
- Policy (lilac) is strictly reactive: "whenever <event> then <command>." It sits after an event and before the next command, orchestrating cross-aggregate reactions. Defined by being triggered by an event.
- Invariant / Business Rule is a constraint enforced inside an aggregate while it handles a command, before any event ("reject order if stock unavailable," "cannot refund more than paid"). It governs whether a command is allowed to succeed; it is not event-triggered and does not issue a command. Classic Event Storming keeps it inside the aggregate or on a separate "business rule" sticky — never as a lilac Policy. Related DDD terms: invariant, specification, guard.
So: policies are reactive logic triggered by events; invariants are constraints maintained within an aggregate's transaction boundary. Model them as distinct things with distinct labels/colors.
Streaming / dataflow contexts: the second plane
The command/aggregate/event/policy grammar is built for transactional domains: an actor issues intent, an aggregate guards invariants, and a decision can succeed or be rejected. A streaming / dataflow context (audio to segment to text to decision; ETL; sensor and inference pipelines) is a different plane. It continuously transforms immutable records that already happened and cannot reject anything. Do not force one grammar onto both. Model two planes joined at a named seam:
sensor -> transform -> transform -> [ TRANSLATION / DECISION ] -> domain event -> policy -> command -> aggregate
|------------- dataflow plane -------------| seam (ACL) |-------------- domain plane --------------|
continuous, immutable, can't reject discrete, has intent, can reject
Everything left of the seam is plumbing; only domain-significant facts cross it. It is the same seam under many names: an Anti-Corruption Layer (Evans), Event Modeling's Translation/Automation slice (Dymitruk), CEP's abstraction step (Luckham), the IoT "threshold-to-event," the ML "score-to-label."
Vocabulary to reach for (name the plane instead of forcing stickies onto it):
- Complex Event Processing (CEP) / event abstraction hierarchy (Luckham): the formal name for a pipeline that lifts a high-frequency stream up levels of meaning (frame -> segment -> utterance -> intent), deriving a smaller number of significant events from many low-level ones. Transform stages are Event Processing Agents, not aggregates.
- Soft real-time (vs hard/firm): a late result degrades quality but does not crash or corrupt. This is the license to keep aggregates, transactions, and ceremony out of the hot path; tactical DDD in the latency path is a known mismatch.
- Supporting / generic subdomain vs core domain (Evans): the pipeline is usually supporting/generic plumbing; the decisions and what consumes them are core. Different subdomains deserve different modeling effort by design, not by accident.
- Domain event vs measurement / telemetry / data-point: the discriminator for what crosses the seam. A raw frame or score is a measurement; a named business fact ("UtteranceTranscribed", "ThresholdExceeded") is a domain event. "A row was inserted" is not a domain event; "a delivery was cancelled" is.
- Data on the inside vs data on the outside (Helland): inside data is rich, mutable, in-process; outside data is immutable, slim, versioned, on the wire. A rich in-process domain event and the slim integration event published from it are not duplication, they are the same fact in two registers.
- Event Modeling's Automation and Translation patterns (Dymitruk): the grammar Event Storming lacks for transforms. A pure transform that reacts to data with no human and no decision is an Automation slice, not a Policy. The same step crossing a system boundary is a Translation slice.
Decision rule (transform stage vs decision point):
- A stage is dataflow infrastructure (a filter / processor / EPA, leave it off the domain wall) when it always runs, validates nothing, rejects nothing, and just produces the next representation. "Do the next step" is a pipeline edge, not a command, and "proceed to the next stage" is not a Policy.
- A stage is a genuine command / decision only when intent can be validated, rejected, or routed
(e.g. "is this relevant?", "dispatch to the agent"), or when the same step is exposed across a service
boundary as a request: an in-process function call is ceremony, but a
TranscribeUtterancemessage sent to a remote worker is a real command. The same operation can be ceremony in-process and a legitimate command across a boundary. - There are no aggregates in the pure pipeline: nothing holds a consistency boundary with invariants over entities. Stateful processors are domain services, not aggregates; do not tag them Aggregate by reflex.
- A node that exchanges no domain event does not belong on the wall: a VAD model handing a probability to an endpointer is part of that step, not a separate external system. Drop it rather than leave it floating.
Sources for this section: Helland, "Data on the Outside vs. Data on the Inside" (CIDR 2005); Luckham, The Power of Events and the CEP literature; Dymitruk, eventmodeling.org (Automation/Translation); Kleppmann, DDIA ch. 11 and "Event Sourcing & Stream Processing at DDD Europe"; Fowler, "What do you mean by Event-Driven?"; de la Torre (Microsoft), "Domain Events vs Integration Events." Re-verify exact wording with WebSearch when it must be precise.
Tactical building blocks are mostly implied
Value Objects and Entities do not belong on an Event Storming wall. The wall is behavioral
(commands/events/policies/aggregates/read models/actors/external systems); tactical structure is implied,
not drawn. Modeling a Value Object (e.g. Money) as its own node is an extension beyond the method — fine
if deliberate, but flag it as tactical and expect it to sit apart from the flow.
Strategic: bounded context is declared, not inferred
A bounded context is a strategic boundary. When tooling groups concepts by context, that context must be declared (an explicit label), never guessed from namespace/folder structure — code layout is an accident, not a domain fact (the same reason you don't infer behavior from method bodies). Draw each context as a labeled boundary box; edges that cross a boundary are the integration points (ACL / published language).
What a code-derived (topological) view loses
A graph extracted from code captures nodes and edges but not the narrative/spatial semantics of a wall:
- Timeline order — events read left-to-right in the order they happen; a topological graph is not a timeline (horizontal position = reachability, not chronology).
- Pivotal events — major state transitions that divide phases/contexts; not derivable.
- Bounded-context grouping — emerges where the ubiquitous language changes; needs a declared boundary.
- Swimlanes — parallel/alternative streams; a flat graph collapses concurrency.
- Hotspots — friction/unknowns; human artifacts, absent from any code-derived view.
Treat a code-derived storm as an always-true scaffold and conversation starter, not a replacement for a workshop.
Recovering a model from code with Hindstorm
Hindstorm (nuget.org/packages/Hindstorm) recovers an Event Storming model from .NET assemblies via
attributes (Hindstorm.Annotations) and exports Mermaid/DOT/JSON (the Hindstorm package). Observations
from use (scope to the package version you have; these are from the 0.2.0 era, verify before relying):
- Concept tags and relation attributes have different valid targets. Concept tags (
[Aggregate],[DomainEvent],[ExternalSystem],[Invariant], ...) allow class/struct, and[ExternalSystem]also allows interface. Relation attributes ([Raises],[ReactsTo],[Handles],[Issues],[Enforces],[Updates]) are valid only on class/struct/method, not interfaces (you get CS0592). To state an edge from an interface-typed external system, put the relation on the method that performs it (e.g.[Raises]/[ReactsTo]onTranscribeAsync); the scanner reads method-level relations on any tagged concept, and it is more precise anyway. - The scanner uses live reflection (
GetCustomAttribute,GetMethods), so the assemblies must be loaded in the generating process with their dependencies resolvable.Scan(IEnumerable<Assembly>)takes many assemblies, so one pass yields a unified model. - An Aspire AppHost cannot scan the app assemblies: Aspire project references are
ReferenceOutputAssembly=false, so the app DLLs are not in the AppHost output and its types are not loadable there. Centralize generation in a dedicated tool project with realProjectReferences to the annotated projects (deps resolve via itsdeps.json) that discovers assemblies from its own output directory, so onboarding a new service is one project reference and no code change. (A future metadata-only scan viaSystem.Reflection.MetadataLoadContextwould remove the loaded-deps requirement but needs Hindstorm to readCustomAttributeDatainstead of instantiating attributes.) - Frame the artifact: emit a
.mdembedding the Mermaid in a fenced block (renders on GitHub; a raw.mmddoes not) with a one-line note on the context's nature so readers judge it on its own terms.
Hindstorm deliberately stays Event-Storming-pure: the dataflow plane (transform processors, measurement
data events, the abstraction hierarchy, translation/ACL seams; see "Streaming / dataflow contexts" above) is
NOT part of its vocabulary, a scope decision made 2026-06-22. To model a streaming pipeline with the existing
stickies, represent transform stages as external systems or domain services, leave raw frames/scores
off the wall as plumbing, and promote only the abstraction-top facts to [DomainEvent]. The two-plane model
is a thinking tool, not a Hindstorm annotation set.
Sources
Brandolini, Introducing EventStorming / "The Picture That Explains Everything"; wwerner event-storming-cheatsheet; Open Group O-AA; IBM refarch-eda; Qlerify; Context Mapper; Avanscoperta; plus practitioner blogs on design-level Event Storming. Re-verify specifics with WebSearch when a claim must be exact — colors and grammar are conventions, not a standard.