Event-Driven Choreography
Status: DRAFT v0.1 — part of the Patterns "composing a system" set.
The two-tier architecture drives processes with orchestrators: a central service issues commands to core services and runs a saga to keep the multi-service write atomic. Choreography is the other way to move a process across a fleet: no central conductor — each service reacts to events and emits its own, and the process emerges from the chain of reactions.
Both are first-class on Benzene, and a real system uses both. This document is about when to choreograph, and exactly how you build it with Benzene's events, transports, and mesh.
Orchestration vs. choreography
| Orchestration (orchestrators.md) | Choreography (this doc) | |
|---|---|---|
| Control | Central — one service directs the process | Distributed — each service decides its own reaction |
| Coupling | Orchestrator knows every step | Emitter knows nothing about who reacts |
| Atomicity | A saga: all-or-nothing across services | None built-in — each reaction succeeds/retries on its own |
| Adding a step | Edit the orchestrator | Add a new consumer; no existing service changes |
| Best for | An invariant that must hold across services (a signup that must not half-apply) | Fan-out reactions that are independently retryable (on signup: send a welcome email, warm a cache, start a trial clock) |
The rule of thumb: orchestrate what must be atomic; choreograph what must merely happen. A process usually has both — a small atomic core (orchestrated as a saga) that emits an event, and a spray of independent reactions (choreographed). Do not use choreography to hold a cross-service invariant; there is no central rollback, and reconstructing one out of scattered compensations is exactly the complexity the saga exists to remove.
How you build it with Benzene
An event is a topic
Choreography needs no new concept. A domain event — tenant:created, order:shipped — is a
topic like any other, and a reaction to it is an
ordinary message handler. The only thing that
makes it an "event" rather than a "command" is intent: nobody is waiting for a result, and the
emitter does not know or care who handles it.
Emitting an event
An emitter publishes fire-and-forget through the same outbound sender the
two-tier calls use — a topic and a payload, with a Void response type
so the caller does not wait:
(informative, .NET)
// Fire-and-forget: Void response type => no wait, no reply.
await sender.SendAsync<TenantCreated, Void>("tenant:created", new TenantCreated { TenantId = id });
Where tenant:created goes is a routing-table entry,
declared once at startup — SNS for fan-out, EventBridge for bus-routed events, SQS for a point-to-
point queue:
services.AddOutboundRouting(routing => routing
.Route("tenant:created", pipeline => pipeline.UseSns(tenantEventsTopicArn))
// or onto an EventBridge bus, where the topic becomes the event's detail-type:
.Route("order:shipped", pipeline => pipeline.UseEventBridge(source: "orders", eventBusName: "domain")));
Benzene tags the message so a Benzene consumer can route on it with no extra configuration:
- SNS / SQS put the topic in the
topicmessage attribute. - EventBridge maps the topic to the event's
detail-type, the source toSource, and embeds Benzene's wire headers (correlation id,traceparent) insidedetailunder a reserved key — because EventBridge has no native message attributes. The inbound side lifts them back out, so correlation and trace context survive the hop.
Reacting to an event
A reacting service mounts the matching inbound transport and writes a handler for the event topic — identical in shape to a command handler, because to Benzene it is one:
app.UseAwsLambda(events => events
.UseSns(sns => sns.UseMessageHandlers()) // topic from the "topic" attribute
.UseEventBridge(bus => bus.UseMessageHandlers())); // topic from detail-type
[Message("tenant:created")]
public class SendWelcomeEmailOnTenantCreated : IMessageHandler<TenantCreated, Reacted>
{
public async Task<IBenzeneResult<Reacted>> HandleAsync(TenantCreated message) { /* react */ }
}
Adding a second reaction is adding a second handler in a second service that subscribes to the same topic. The emitter is untouched, and never learns the reaction exists — that decoupling is the whole point of choreographing.
Use the request/response handler interface even though nobody reads the response: on a queue-shaped
transport the handler's result status is what settles the delivery — success acks, failure nacks
and the broker redelivers. The single-generic IMessageHandler<TRequest> returns a plain Task and
so has no way to say "this did not work", which on an at-least-once transport means a failed
reaction is acked and lost.
Events from non-Benzene producers
An event from an AWS service or a non-Benzene producer will not carry Benzene's topic attribute.
Two clean options:
- A queue (or subscription) per event type, and set the topic for everything on that pipeline
with
UsePresetTopic("s3:object-created")beforeUseMessageHandlers()— every message on that queue routes to a fixed topic.UseTopicFrom(ctx => …)is the dynamic version, computing the topic from the payload (a discriminator field, a routing key). (Preset/derived topics are supported on the queue-shaped transports — SQS, Service Bus, Event Hub, Queue Storage, and the self-hosted workers; check your transport before relying on it.) - EventBridge already carries a
detail-type, so a foreign bus event routes on that detail-type directly — declare a handler for it.
Choreography is visible in the mesh — for free
Choreography's classic drawback is that the flow lives nowhere: no orchestrator to read, so "what
reacts to what" is folklore. On Benzene it is not folklore. An emitter declares the events it
publishes and a consumer serves them as handlers, so the mesh draws
every emitter → reaction edge from their contract documents (§4), before a message has flowed. A
consumer also stamps traceparent from the inbound event's span; keep that propagation on (a
meshed service's M2, mesh.md §7.1) and the fleet view shows which of
those edges real traffic exercises (§4.2). The thing that makes choreography hard to see
everywhere else, Benzene gives you as a live diagram.
Reliability: at-least-once, so make reactions idempotent
SNS, SQS, and EventBridge are at-least-once — a reaction can be delivered more than once (a redelivery, a retry, a duplicate publish). Choreographed reactions must therefore be idempotent: processing the same event twice must have the same effect as once.
Benzene ships the seam for this — Benzene.Idempotency. Add UseIdempotency() to a reaction's
pipeline and it derives a key (an idempotency-key header if present, else a hash of topic+body),
atomically claims it in a store, and runs the handler only on the first sighting:
sns.UseIdempotency().UseMessageHandlers();
The in-memory store is single-process; for a fleet of Lambdas you supply an IIdempotencyStore
backed by an atomic conditional write (DynamoDB attribute_not_exists, Redis SET NX). Idempotent
reactions are what make at-least-once safe — and they are the same discipline the
outbox relies on downstream, and that a saga's compensations already
require.
There is no automatic cross-service rollback in choreography. If a reaction fails, it retries (and eventually dead-letters) on its own — the emitter has already moved on. Choreograph reactions that are safe to retry independently; keep anything that must be undone-as-a-unit inside an orchestrated saga.
Events are contracts
An emitted event is a published contract other services depend on — evolve it as carefully as a request type. Version an event's payload with payload-schema versioning so old consumers keep reading it, and give a breaking event a topic version rather than redefining the old topic under everyone's feet.
Checklist
Choreography is well-formed when:
- Events are topics, emitted fire-and-forget (
Voidresponse) via the routing table. - Emitters know nothing about consumers; a new reaction adds a consumer and changes no existing service.
- Reactions are idempotent (
UseIdempotency+ a distributed store), because delivery is at-least-once. - Cross-service atomic invariants stay in an orchestrated saga, not in choreography.
- Events are declared, so the mesh draws the choreography graph; trace context is propagated, so it shows which edges are exercised.
- Events are versioned as the contracts they are.
See also: transactional outbox (so the events you choreograph on are never lost), and CQRS & read models (a major consumer of domain events).