Composite events¶
Verified by tests
CompositeEventContractTests, CompositeEventBaseTests, CompositeInboxFanoutTests, DispatchOutboxCollectorTests, DispatchFanoutControlTests, NoRebroadcastGuardTests, EventFlagsTests, EventFlagsTransportTests, InboxDispatchWorkerTests, TransportConsumerWorkerCompositeNoExpandTests, DispatcherCompositePublishFanoutTests, DispatcherNoRebroadcastGuardTests, ReceptorRegistryQueryGeneratorTests — library CI run #31657041675 (2026-08-13)
A composite event bundles many inner events into one transport hop. A bulk operation that produces N domain events (e.g. "orders imported" emitting hundreds of field events) sends one wire message instead of N — one outbox row, one publish, one receive — then fans out into the N inner events at the receiver. The composite itself is never written to the event store; only the expanded inner events are persisted, so replay reads the inner events back as if no composite ever existed.
A composite travels over transport exactly like an ordinary IEvent (one
outbox row, one publish) — the single difference is that it is not
event-stored. It fans out at every destination service, and the children,
wherever they fan out, are local-published (event store + receptors +
perspectives) and never rebroadcast.
When to reach for it¶
flowchart TD
Start["Producer wants to emit many events in one operation"]
Q1{"Is the mutation uniform across a whole scope<br/>(no explicit id list)?"}
Collective["ICollectiveEvent<br/>(scope is the descriptor; one SQL UPDATE)"]
Batch["The producer hand-crafts a specific batch of inner events<br/>(all inheriting the composite's single stream)"]
Q2{"Is per-message overhead (network hop, broker dispatch,<br/>outbox/inbox row, serialization framing)<br/>the cost you want to amortize?"}
Composite["ICompositeEvent ← THIS<br/>one wire hop, N inner events"]
Start --> Q1
Q1 -->|YES| Collective
Q1 -->|NO| Batch
Batch --> Q2
Q2 -->|YES| Composite
style Composite fill:#d4edda,stroke:#28a745,stroke-width:2px
Composites pay off when the per-message overhead dominates the actual processing cost. Canonical cases:
- A bulk import producing N
OrderCreatedEventinstances. - A migration tool that emits one event per affected record as a single unit.
- Any batch operation that yields many domain events from one action.
Composites sit next to collective events — a composite is a hand-crafted batch of inner events (all landing on the composite's one stream); a collective event is a scope-wide uniform mutation applied as one SQL UPDATE. They share no inheritance and run on separate code paths; both can appear in the same workflow (a bulk import emits a composite; a tenant cleanup emits a collective event).
For high inner-event counts, pair composites with the message-body offload: a 5,000-inner-event composite easily exceeds the Azure Service Bus Standard 256 KB ceiling, so the offload path moves the body to blob storage transparently and substitutes a small claim envelope on the wire.
Two fan-out points¶
A composite fans out at every destination that receives it — and the publishing service is itself a destination:
- The publishing service, at publish — when a service publishes a composite in its own domain, it expands and local-publishes each inner event right there, materializing its own children immediately. The composite also goes over transport; its own transported copy loops back and is echo-discarded (it already fanned out), so there is no double fan-out. See Publish-time local fan-out.
- Every other subscribing service, on receive — the composite arrives over
transport and fans out at the dispatch seam (
InboxDispatchWorker), the same way. See Dispatch lifecycle.
Both paths reuse the ordinary local-publish / receive machinery — there is no bespoke fan-out transport.
The load-bearing principle:
A composite is an ordinary message everywhere except one seam — the fan-out — and that seam sits inside the durable inbox / dispatch / retry / DLQ envelope, not outside it at the transport edge.
That is what makes a composite recoverable: if fan-out fails, the composite is
just an inbox row that failed → normal retry → dead-letter via the existing
IDeadLetterStore.MoveAsync(wh_inbox, …). There is no separate
composite-failure path.
Historical note. In an earlier design the receiver expanded a composite at the transport edge (
TransportConsumerWorker) before it became an inbox row. That was removed: expanding there orphaned the composite from durability/retry. TodayTransportConsumerWorkerstores a composite as an ordinary inbox row (IsEvent == false) and does not expand it — the dispatch seam does. This is locked byTransportConsumerWorkerCompositeNoExpandTests.
The three message roles¶
- Composite — dispatchable but never event-stored. Lives transiently as an inbox row: received → (pre-fanout receptors) → fan out → deleted.
- Children (inner events) — ordinary received events, produced by fan-out as inbox rows, processed normally (event store + perspectives + receptors). They never outbox (see the no-rebroadcast invariant).
- Everything downstream of fan-out sees only children — no composite awareness.
The contract¶
public interface ICompositeEvent : IMessage {
// Inner events this composite expands into, in producer-yielded order.
// Enumerated once at receive time.
IEnumerable<IMessage> InnerEvents { get; }
// Defensive cap. A producer that yields, say, 100,000 inner events from a bug
// is caught here rather than corrupting the receiver. Default 10,000.
int MaxInnerEventsAllowed => 10_000;
// How fan-out is triggered (Auto by default; Manual defers to a pre-fanout receptor).
FanoutMode FanoutMode => FanoutMode.Auto;
// Per-child failure policy (Independent by default; Atomic = all-or-nothing).
FanoutAtomicity Atomicity => FanoutAtomicity.Independent;
}
ICompositeEvent extends IMessage, so composites flow through the existing
dispatcher / outbox / transport surface — no parallel pipeline. The dispatcher
stamps EventFlags.Composite on the outgoing envelope; the receiver stores the
composite as an inbox row and the dispatch worker recognizes the typed
ICompositeEvent payload and fans it out.
Resolved design decisions¶
| Question | Decision |
|---|---|
Inner-event StreamId |
All inner events inherit the composite's StreamId. Producers needing per-inner streams emit separate envelopes (no composite). |
| Ordering | Producer-yielded order (the order InnerEvents enumerates). Matches single-row outbox storage semantics. |
| Event-store replay | Composite is wire-only; only expanded inner events persist. Producers can stop emitting a composite type at any time without affecting historical replay. |
| Lifecycle hooks | Fire per-inner-event. Downstream never sees the composite. |
| Failure atomicity | Configurable per composite via Atomicity — Independent (default) or Atomic. A cap breach always dead-letters the whole composite regardless. |
Authoring¶
Derive from CompositeEventBase — one line for the common case:
public sealed class OrderBulkImportComposite : CompositeEventBase;
// producer:
var composite = new OrderBulkImportComposite {
StreamId = orderStreamId, // every inner event inherits this stream at the receiver
Inner = orderFieldEvents, // List<IMessage>, in producer-yielded order
};
composite.EnsureWithinCap(); // producer-side fail-fast before publishing
await dispatcher.PublishAsync(composite, ct);
The base carries the shared [StreamId], the List<IMessage> Inner (typed
concretely so each element serializes with its polymorphic $type
discriminator — an interface-typed collection loses that on the source-gen
path), an init MaxInnerEventsAllowed (default 10,000), and
EnsureWithinCap() (throws InvalidOperationException when the inner count
exceeds the cap). InnerEvents is a [JsonIgnore] computed view over Inner;
inner events share the composite's stream.
Dispatch lifecycle¶
On the receive side, fan-out happens at the dispatch seam
(InboxDispatchWorker.ProcessOneInnerAsync), inside the durable retry/DLQ
envelope:
claim composite inbox row (InboxDispatchWorker.ProcessOneInnerAsync)
→ deserialize payload → typed ICompositeEvent
→ PRE-FANOUT: fire inline receptors under an ambient DispatchOutboxCollector
→ FANOUT: CompositeInboxFanout.TryExpand → N child inbox messages
→ COMMIT: one HandlerCommitRequest {
NewInboxMessages = children,
NewOutboxMessages = pre-fanout emissions,
InboxCompletion.Status = EventStored }
→ process_inbox_completions stores children + pre-fanout events
AND DELETEs the composite (one transaction)
→ children dispatch normally
- Recognition — the source generator (
ReceptorRegistryQueryGenerator) discovers every concreteICompositeEventtype and lists it inAnyConsumerTypes, so the receive-boundary drop-gate keeps a composite alive long enough to reach the dispatch seam (a composite has no receptor / perspective / tag of its own). The abstractCompositeEventBaseis skipped — it is never dispatched. - Fan-out is AOT-clean —
CompositeInboxFanoutbuilds each child as aMessageEnvelope<IMessage>and serializes it throughIEnvelopeSerializer.SerializeEnvelope, which derives the wire type from the inner event's runtime type. No runtime reflection (it sidesteps theActivator.CreateInstance+MakeGenericMethodhops the removed transport-edge expander used). - Atomicity — the children and the composite-row deletion commit in one
transaction (
process_inbox_completions). TheEventStoredstatus bit (value2, i.e.1 << 1) drives the DELETE of the composite row. - Failure — a cap breach (
MaxInnerEventsAllowed) or a child-serialization error underAtomicreturns no partial fan-out; the composite row is dead-lettered withMessageFailureReason.CompositeInnerEventLimitExceeded(15) orMessageFailureReason.CompositeExpansionFailure(16) viaIDeadLetterStore.MoveAsync. When noIDeadLetterStoreis wired, it falls back to a terminal mark-Publishedcompletion so the row never re-claims.
What each child inherits¶
CompositeInboxFanout._buildChildInbox builds one child inbox message per inner
event:
| Field | Behavior |
|---|---|
Version, DispatchContext, SourceServiceId, SourceCommitSequence, CausedByServiceId, CausedByCommitSequence, StateOnly |
Copied from the composite envelope (re-delivery bundles may override the source service id / commit sequence with the original origin identity). |
Hops |
A composite-lineage chain: a fresh creation hop whose CausationId is the composite's MessageId and CausationType is the composite type name, followed by the composite's own hops. Built once and shared by reference across the whole batch, so "these events came from composite X" is queryable. |
MessageId |
Fresh UUIDv7 per child, so inbox dedup keeps each inner event distinct. (Identity-preserving re-delivery composites keep each child's original id so consumer convergence rides the event-id conflict skip.) |
StreamId |
The composite's stream (recorded as the first hop's AggregateId), falling back to the child's own MessageId when absent. |
Flags |
Stamped EventFlags.NoRebroadcast on both the persisted inbox row and the in-memory envelope. |
Pre-fanout hook¶
A composite's inline receptors fire before fan-out, inside the dispatch
step, so a receptor can validate the batch, stamp per-batch metadata, or emit a
durable BatchReceivedEvent. Their inline emissions are captured by an ambient
DispatchOutboxCollector (which diverts the would-be outbox write into an
in-memory buffer) and folded into the same HandlerCommitRequest as the
fan-out children — so pre-fanout side-effects and children commit
all-or-nothing. A pre-fanout receptor that throws fails the composite inbox
row → normal retry → DLQ, exactly like any other dispatch failure.
Only a composite's inline receptors run at all — _invokePreFanoutHookAsync
invokes just its PreInboxInline / PostInboxInline stages, and only their
emissions fold into the atomic commit. After fan-out, ProcessOneInnerAsync
returns early, so the composite's own detached ([FireAt(...Detached)])
receptors never fire: a composite has no lifecycle of its own past fan-out. The
children then dispatch normally post-fan-out, running their full per-inner-event
lifecycle — there is no composite awareness downstream.
DispatchOutboxCollector is AsyncLocal-backed so it flows across the fresh DI
scope the dispatcher's outbox seam opens. When no collector is open, outbox
writes go through normally — zero behavior change off the collecting path.
Fan-out control¶
Fan-out is zero-config by default, with a declarative knob on the composite and an imperative override from a pre-fanout receptor.
Declarative (on the composite type / CompositeEventBase):
FanoutMode—Auto(default, fans outInnerEvents) |Manual(a pre-fanout receptor drives it; nothing fans out without an explicit directive).Atomicity—Independent(default; a child that fails to serialize is dropped and the rest fan out — "one bad child doesn't sink the batch") |Atomic(any child failure dead-letters the whole composite — use when the inner events are one logical unit). A cap breach always dead-letters the whole composite regardless of atomicity.
public sealed class OrderBulkImportComposite : CompositeEventBase {
public OrderBulkImportComposite() {
Atomicity = FanoutAtomicity.Atomic; // an order's field events are one unit
}
}
Imperative — a pre-fanout receptor calls DispatchFanoutControl.Set(...)
(ambient, like the collector) to impose a FanoutDirective, which takes
precedence over FanoutMode:
Proceed(default) — fan out the composite's ownInnerEvents.Skip— suppress fan-out; the receptor handled the composite. The composite row is still deleted and any emitted events still commit.ReplaceWith(children)— fan out a receptor-supplied set instead (filter / transform / re-key before the children are created).
DispatchFanoutControl is AsyncLocal-backed. The dispatch worker opens a
control before invoking the composite's receptor and reads the directive after;
when no control is open, Set is a no-op, so a misfired call outside the
pre-fanout window cannot corrupt unrelated dispatch.
The no-rebroadcast invariant¶
One composite on the wire; children are received-events confined to the inbox → event-store → local-processing path. Children never outbox.
Correct by construction — fan-out writes children to the inbox/received
path, never PublishAsync; the composite was already delivered to every
subscriber of its topic. Defended in depth:
- Hop-based echo suppression (primary). Children carry the composite's hop lineage, so the owned-echo suppressor treats them as received-from-upstream and won't re-publish them. This covers the persisted-and-re-claimed child too.
EventFlags.NoRebroadcastguard (explicit). Fan-out stamps every child — both its persistedInboxMessage.Flagsand its in-memory envelopeFlags— withNoRebroadcast. The outbox-enqueue boundary (Dispatcher.PublishToOutboxAsync/PublishToOutboxDynamicAsync, viaNoRebroadcastGuard.ShouldSuppress) hard-drops any publish whose source envelope carries the flag. Even a receptor that explicitly re-publishes a fan-out child it is processing is stopped at the gate.
Publish-time local fan-out¶
A composite fans out at every destination that receives it — and the
publishing service is itself a destination. Rather than make the publishing
service wait to receive its own transported copy back, it fans the composite out
locally, at publish (Dispatcher._fanOutCompositeLocallyAtPublishAsync):
flowchart TD
Publish["OrderService: PublishAsync(OrderBulkImportComposite)<br/>(owned domain)"]
Step11["1.1 expand → local-publish each inner event<br/>(DispatchModes.Local = local receptors + event store, NO transport)"]
Children["Children land in the event store<br/>+ fire receptors/perspectives"]
Step12["1.2 PublishToOutboxAsync(composite) → transport<br/>(ONE wire row; composite is NOT event-stored)"]
Others["Other subscribing services receive it<br/>and fan out on the dispatch seam"]
Loopback["OrderService's own transported copy loops back<br/>→ echo-discarded (already fanned out at 1.1)"]
Publish --> Step11
Step11 --> Children
Publish --> Step12
Step12 --> Others
Others --> Loopback
- 1.1 is gated on the composite being in an owned namespace
(
_isOwnedNamespace) — a service only fans out locally for composites in a domain it owns. Inner events are local-published through the ordinaryCascadeMessageAsync(DispatchModes.Local)path, so they reuse the exact event-store + receptor + perspective machinery a normalPublishAsyncuses, minus the per-event outbox row.Atomicitygoverns child failure at publish:Atomicpropagates (the whole publish fails);Independentlogs and continues. An over-cap composite throwsInvalidOperationExceptionat publish (producer-side fail-fast). A non-owned composite does not fan out at publish — it fans out on receive at the owning services. - 1.2 is the ordinary outbox publish — the composite travels like any owned event.
- No double fan-out — because the publishing service already fanned out at 1.1, its own loopback copy is echo-discarded by the normal owned-echo suppression (no special case: a composite in its own namespace is a self-echo just like an owned event).
Net effect: the inner events land in the event store and trigger their receptor cascades exactly as if they had been published individually, at every service that consumes the domain — the composite is purely a transport/packaging optimization, not a change in downstream semantics.
Treatment flags (extending EventFlags)¶
The no-rebroadcast guard generalizes into a small convention. EventFlags (the
per-event bitmask on the envelope / inbox / outbox / event-store rows) holds two
kinds of bit:
- Category — what the event is:
Composite(1 << 1),Collective(1 << 0). - Treatment — tell the framework to bypass or alter some functionality for
this event:
NoRebroadcast(1 << 2) today; future candidates likeSuppressNotificationsorSkipPerspectives.
Treatment flags are not composite-specific — any event can carry one. An
event opts in by a marker interface (the builders already derive
Composite/Collective via payload is IXxxEvent, so a new treatment slots
into the same |-chain) for a type-level treatment, or by setting the
per-instance IMessageEnvelope.Flags carrier at publish. Composites are just
one producer: fan-out propagates the composite's treatment flags to every
child (today the child stamp carries NoRebroadcast).
The rule that keeps this honest: a treatment flag ships with the gate that reads it — a bit nothing branches on is dead code. The mechanism (the bitmask, the marker-interface derivation, the per-instance carrier, composite→child propagation) is in place; each new bit is a small, localized addition shipped with its consumer.
Failure modes¶
| Failure mode | Reason code | Behavior |
|---|---|---|
Composite yields more than MaxInnerEventsAllowed inner events |
CompositeInnerEventLimitExceeded (15) |
Dead-letter the whole composite regardless of atomicity — a runaway producer/enumerator. |
Inner event is null / child serialization fails under Atomic |
CompositeExpansionFailure (16) |
All-or-nothing — no partial inner events recorded; whole composite dead-letters. |
Inner event is null / child serialization fails under Independent |
— | Drop the bad child (logged), fan out the rest. |
| Producer builds an over-cap composite | — | EnsureWithinCap() throws InvalidOperationException; at publish, _fanOutCompositeLocallyAtPublishAsync throws the same synchronously. |
Code ↔ tests¶
| Concern | Code | Tests |
|---|---|---|
| Composite marker / authoring | ICompositeEvent, CompositeEventBase |
Messaging/CompositeEventBaseTests.cs |
| Dispatch recognition (drop-gate) | ReceptorRegistryQueryGenerator (_extractCompositeEntry → AnyConsumerTypes) |
Generators.Tests/ReceptorRegistryQueryGeneratorTests.cs (Generator_WithCompositeEvent_HasAnyConsumerReturnsTrueAsync) |
| Dispatch-time fan-out | CompositeInboxFanout, InboxDispatchWorker |
Messaging/CompositeInboxFanoutTests.cs, Workers/InboxDispatchWorkerTests.cs (CompositeMessage_FansOut*, CompositeOverCap_DeadLetters*) |
| Publish-time local fan-out (1.1) | Dispatcher._fanOutCompositeLocallyAtPublishAsync, Dispatcher.PublishAsync |
Dispatcher/DispatcherCompositePublishFanoutTests.cs |
| Pre-fanout hook (atomic emit) | DispatchOutboxCollector, InboxDispatchWorker._invokePreFanoutHookAsync |
Messaging/DispatchOutboxCollectorTests.cs, Workers/InboxDispatchWorkerTests.cs (CompositeWithPreFanoutReceptor_*) |
| Fan-out control | FanoutMode/FanoutAtomicity/FanoutDirective, DispatchFanoutControl |
Messaging/DispatchFanoutControlTests.cs, Messaging/CompositeInboxFanoutTests.cs, Workers/InboxDispatchWorkerTests.cs (CompositeDirective_*, CompositeFanoutMode_Manual_*) |
| No-rebroadcast guard | EventFlags.NoRebroadcast, NoRebroadcastGuard, CompositeInboxFanout stamp |
Messaging/NoRebroadcastGuardTests.cs, Messaging/CompositeInboxFanoutTests.cs (TryExpand_ChildrenCarryNoRebroadcastFlag), Dispatcher/DispatcherNoRebroadcastGuardTests.cs |
Treatment-flag convention (EventFlags) |
EventFlags (category vs treatment) |
Messaging/EventFlagsTests.cs (bit-position locks), Messaging/EventFlagsTransportTests.cs |
| No transport-edge expansion | TransportConsumerWorker |
Workers/TransportConsumerWorkerCompositeNoExpandTests.cs |