Composite Events¶
Verified by tests
CompositeEventContractTests, CompositeEventBaseTests, CompositeInboxFanoutTests, EventStoreAppendBatchTests, NoRebroadcastGuardTests, TransportConsumerWorkerCompositeNoExpandTests, DispatcherCompositePublishFanoutTests — library CI run #31657041675 (2026-08-13)
A composite event is a wire-only optimization for bulk operations that produce many domain events. Instead of emitting N envelopes (one per inner event), the producer emits a single envelope carrying an IEnumerable<IMessage> InnerEvents. The receiver expands it into N envelopes inheriting the composite's identity context, then proceeds as if each inner event arrived independently.
Composite types are never persisted in the event store — only the expanded inner events. Replay reads inner events back as if no composite existed.
When to use¶
Composites pay off when the per-message overhead (network round-trip, broker dispatch, outbox row, inbox row, serialization framing) dominates the actual processing cost. Bulk operations are the canonical case:
- A bulk import producing N
JobCreatedEventinstances. - A migration tool that updates N records in a single transactional unit.
- A batch operation that emits one event per affected row.
Use the body offload feature alongside composites for high inner-event counts — a 5,000-inner-event composite easily exceeds the Azure Service Bus Standard 256 KB ceiling, and the offload path moves the body to blob storage transparently.
The contract¶
public interface ICompositeEvent : IMessage {
/// Yields the inner events this composite expands into. Receivers
/// enumerate this exactly once at receive time, in producer-yielded order.
IEnumerable<IMessage> InnerEvents { get; }
/// Defensive cap. Producers that accidentally yield 100,000 inner
/// events from a bug get caught here rather than corrupting the
/// receiver's batched event-store append. Default 10,000.
int MaxInnerEventsAllowed => 10_000;
/// How fan-out is triggered. Auto (default) fans out InnerEvents
/// automatically at the dispatch seam; Manual defers to a pre-fanout
/// receptor that drives fan-out via DispatchFanoutControl.
FanoutMode FanoutMode => FanoutMode.Auto;
/// Per-child failure policy. Independent (default) drops a child that
/// fails to serialize and fans out the rest; Atomic dead-letters the
/// whole composite on any child failure (all-or-nothing).
FanoutAtomicity Atomicity => FanoutAtomicity.Independent;
}
ICompositeEvent extends IMessage, so composites flow through the existing dispatcher/outbox/transport surface — no parallel pipeline. The marker is enough; the builders detect it via payload is ICompositeEvent and stamp EventFlags.Composite on the envelope. A convenience base class, CompositeEventBase, implements the interface with sensible defaults. On the receiver, the composite arrives as an ordinary inbox row; InboxDispatchWorker detects the composite payload at the dispatch seam and triggers fan-out — TransportConsumerWorker deliberately does not expand composites.
Resolved design decisions¶
| Question | Decision | Rationale |
|---|---|---|
| Failure atomicity | Configurable via Atomicity: Independent (default) drops a failed child and fans out the rest; Atomic dead-letters the whole composite on any child failure. A cap breach always dead-letters the whole composite. |
Bulk imports usually prefer partial progress; opt into all-or-nothing per type. |
Inner-event StreamId |
All inner events inherit the composite's StreamId. |
Simplest; producers needing per-inner stream IDs emit separate envelopes. |
| Ordering | Producer-yielded order (sequential within composite). | Matches single-row outbox storage semantics. |
| Event-store replay | Composite is wire-only for the event store; only expanded inner events persist. | Producers can stop emitting a composite type at any time without affecting historical replay. |
| Lifecycle hooks | The composite is dispatchable and hookable: a pre-fanout IReceptor<TComposite> fires before any child exists; after fan-out the per-inner-event lifecycle runs normally. |
Lets a receptor validate, transform, or veto fan-out; downstream sees only children. |
Dispatch-seam fan-out¶
CompositeInboxFanout.TryExpand(composite, sourceEnvelope, scope) is the fan-out entry point used by InboxDispatchWorker. It enumerates InnerEvents, validates against MaxInnerEventsAllowed, and builds one child InboxMessage per inner event (each wrapped in a concrete MessageEnvelope<IMessage> — no runtime reflection on this path). The handler-commit path stores the children while deleting the composite row.
Each child envelope inherits:
| Field | Behavior |
|---|---|
Version, DispatchContext, SourceServiceId, SourceCommitSequence, CausedByServiceId, CausedByCommitSequence |
Copied from the composite envelope. |
Hops |
A composite-lineage hop chain: one fresh creation hop pointing back to the composite (CausationId = composite MessageId, CausationType = composite type name) followed by the composite's own hops. Built once and shared by reference across all children — no per-child duplication for a 5K-inner composite. |
MessageId |
Fresh per child. Receiver inbox dedup treats each inner event as a distinct message. Reusing the composite's MessageId would silently dedup all-but-one inner event on redelivery. |
Flags |
Stamped EventFlags.NoRebroadcast: children are confined to the inbox → event-store → local-processing path and are never re-published to the outbox (the composite already crossed the wire). |
Before fan-out, any registered pre-fanout receptor (IReceptor<TComposite>) fires. It can impose a FanoutDirective via DispatchFanoutControl — Proceed, Skip, or ReplaceWith(children) — which takes precedence over the composite's declarative FanoutMode. With FanoutMode.Manual and no directive, no automatic fan-out occurs.
Cap enforcement¶
The fan-out counts as it iterates. Reaching MaxInnerEventsAllowed + 1 returns FanoutOutcome.CapExceeded and the partial expansion is discarded — no child inbox rows leak past the failure boundary. InboxDispatchWorker dead-letters the composite with MessageFailureReason.CompositeInnerEventLimitExceeded. Cap breaches are treated as producer bugs (runaway enumerator), so the whole composite dead-letters regardless of the Atomicity setting.
To raise the cap for a specific composite type, override the property:
public class LargeBulkComposite : ICompositeEvent {
public IEnumerable<IMessage> InnerEvents => /* … */;
public int MaxInnerEventsAllowed => 50_000;
}
Batched event-store append¶
For composite expansion at scale (5K inner events), looping IEventStore.AppendAsync per inner event would be O(N) round-trips. IEventStore.AppendBatchAsync<TMessage> provides a single-call batch entry point:
Task AppendBatchAsync<TMessage>(
IReadOnlyList<(Guid streamId, MessageEnvelope<TMessage> envelope)> entries,
CancellationToken cancellationToken = default);
The default implementation loops AppendAsync per entry — correct but slow. Backends MAY override for bulk performance (as of this commit no backend overrides it yet; the interface XML docs sketch the intended EFCore Postgres bulk strategy — INSERT INTO wh_event_store … SELECT FROM jsonb_array_elements(@payload) in a single transaction — as follow-up work).
Atomicity is backend-defined: the default loop is per-item; an overriding backend may use a single transaction for all-or-nothing semantics. The XML doc on the interface flags this so consumers know what to depend on.
Failure modes¶
| Failure mode | Reason code | Behavior |
|---|---|---|
Composite yields more than MaxInnerEventsAllowed inner events |
MessageFailureReason.CompositeInnerEventLimitExceeded |
Dead-letter the whole composite (regardless of Atomicity); producer likely has a runaway enumerator. |
| Child is null or fails to serialize during fan-out | Atomicity == Independent (default): child is dropped, the rest fan out. Atomicity == Atomic: MessageFailureReason.CompositeExpansionFailure |
Under Atomic, all-or-nothing — the whole composite dead-letters and no partial children are recorded. |