Articles · Event Sourcing ·029
Rich Event Metadata
Runnable sample on GitHub
Sample:
samples/029-rich-event-metadata— an event-sourced order that stamps correlation id, causation id, actor, and tenant onto every event. Run its tests withdotnet test(needs Docker for PostgreSQL).Prerequisites: 005 — Event Sourcing Basics.
Overview
Every event Relay appends already carries a little metadata: the CommandName that produced it and a
Timestamp. That is stored alongside the event — not inside the payload — so the event stays a clean
business fact (OrderPlaced { Sku, Quantity }) while the framework keeps a small envelope of context
next to it.
Most real systems want more in that envelope. When OrderPlaced lands six services downstream and
something goes wrong, the questions are operational: which request was this part of (correlation id),
what triggered it (causation id), who did it (actor/user), for which customer (tenant). None of
that belongs in the domain event — but all of it belongs on the event. Relay’s answer is the
IEventMetadataEnricher: implement it, register it in DI, and the transactional pipeline stamps
your extra headers onto every appended event, ready to read back off the stored log.
This article takes the event-sourced aggregate from article 005 — unchanged — and adds one enricher plus a small ambient context, so every stored event carries a full audit/tracing envelope.
Why this exists
The default {CommandName, Timestamp} is enough to know what kind of command wrote an event and
when. It is not enough to answer the questions that actually come up in production:
- Audit. “Who placed this order?” The event says what happened, not who did it. An
ActorIdheader turns the event stream — already your source of truth — into a complete, tamper-evident audit log, with no parallel “who changed what” table to drift out of sync. - Correlation / causation tracing. A single user action fans out into many commands and messages across services. A shared correlation id ties all the resulting events together; a causation id records which message directly triggered each step. Together they let you reconstruct the whole causal tree from the stored events alone.
- Multi-tenant stamping. In a shared-database multi-tenant system (article 020), stamping the
TenantIdonto every event means the tenant a fact belongs to travels with the fact, surviving into projections, exports, and audits — instead of being inferred later from a join that might be wrong.
Putting this on the event envelope rather than in the payload keeps the domain event a pure business fact (so it versions cleanly and replays identically forever) while the operational context rides alongside it. The enricher abstraction means the domain code, the handlers, and the aggregate never learn about correlation ids at all — it is a single cross-cutting registration.
When to use this
- Anything audited or regulated. Stamp the actor (and often tenant) onto every event so the stream is the audit log — “who did what, when, in what order” without a parallel table.
- Distributed flows you need to trace. Stamp a correlation id (and causation id) so you can stitch a user action’s events back together across services and reason about cause and effect.
- Multi-tenant systems. Stamp the tenant so each fact carries its owner into every downstream view.
- Anything you’d otherwise smuggle into the payload. If you keep adding
UserId/TraceIdfields to your business events purely for plumbing, that context belongs in metadata instead.
When not to use this
- When the default is enough. Plenty of internal systems are happy with
CommandName+Timestamp. Don’t add enrichers speculatively — metadata lives on every event forever. - For large or rich data. Metadata is stored per event; it is for small, flat headers (ids, names), not blobs, nested objects, or anything you’d query. Queryable facts belong in the payload or a projection (article 007).
- For secrets or PII. Email, full names, tokens — do not put them in metadata (see Production considerations). Stamp an opaque id and resolve the rest elsewhere.
- As a transport for business state. If a downstream decision depends on a value, it is a business fact and belongs in the event payload, not a header that handlers might ignore.
Concepts
Default metadata. With no enrichers, EventMetadata.Build(commandName) produces exactly
{"CommandName": …, "Timestamp": …} — identical to before enrichers existed. The framework writes those
two keys last, so they are always present and authoritative.
IEventMetadataEnricher. A one-method contributor:
public interface IEventMetadataEnricher
{
void Enrich(IDictionary<string, object?> metadata, string commandName);
}
It is handed the mutable metadata bag for a command and adds entries to it. Enrichers must be cheap and side-effect-free — they run inside the command’s commit path. You can register any number of them; each runs for every command’s event append.
EventMetadata.Build. The static builder the pipeline calls:
EventMetadata.Build(commandName, enrichers) runs every enricher into a fresh dictionary, then writes
CommandName and Timestamp last (so an enricher can’t spoof them), and serializes the result to JSON.
Where it runs in the append path. The innermost TransactionExecutor (the priority-1000
transaction behavior from article 005) builds the metadata once per command and stamps that same JSON
onto every event it appends in that transaction. Crucially, it resolves the enrichers from the
command’s own DI scope — serviceProvider.GetServices<IEventMetadataEnricher>() — the same scope the
handler ran in. So an enricher can read a scoped ambient context the handler populated.
Reading it back. Each stored EventData exposes the JSON as its Metadata string property.
IEventStore.GetEventsAsync(aggregateId, fromVersion) returns the stream, and you parse
event.Metadata to read the headers — exactly what the sample’s tests assert.
Architecture
sequenceDiagram
participant H as PlaceOrderCommandHandler
participant Ctx as RequestContext (scoped)
participant Tx as TransactionExecutor (priority 1000)
participant En as RequestContextEnricher
participant ES as IEventStore (relay_events)
H->>Ctx: set CorrelationId / ActorId / TenantId
H->>H: Order.Place(...) → raises OrderPlaced (v0)
Tx->>En: EventMetadata.Build("PlaceOrderCommand", enrichers)
En->>Ctx: read CorrelationId / ActorId / TenantId
En-->>Tx: { CorrelationId, ActorId, TenantId } + { CommandName, Timestamp }
Tx->>ES: AppendMany([OrderPlaced v0], metadata=<that JSON>)
Note over Tx,ES: same metadata stamped on EVERY event in the transaction
ES-->>H: GetEventsAsync(id) → event.Metadata carries the headers
The enricher sits in the append path but out of the domain: the aggregate and handler raise business events, and the cross-cutting context is attached as those events are persisted.
Building it step by step
The full sample is in samples/029-rich-event-metadata.
1. The aggregate and events — unchanged from article 005’s style
[AggregateType("ordering.order")]
public sealed class Order : AggregateRoot<Guid>
{
public int Quantity { get; private set; }
protected override bool ApplyEventsOnRaise => true;
private Order() { }
public static Order Place(Guid id, string sku, int quantity) { /* Guard; RaiseEvent(new OrderPlaced(...)) */ }
public void ChangeQuantity(int q) { Guard.AgainstNegativeOrZero(q, nameof(q)); RaiseEvent(new OrderQuantityChanged(Id, q)); }
protected override void ApplyEvent(IDomainEvent e) => /* mutate from OrderPlaced/OrderQuantityChanged */;
}
No mention of metadata anywhere — that is the point.
2. An ambient, per-command context the enricher will read
public sealed class RequestContext // registered Scoped
{
public string? CorrelationId { get; set; }
public string? CausationId { get; set; }
public string? ActorId { get; set; }
public string? TenantId { get; set; }
}
In a real service this is filled at the edge — HTTP middleware reads the X-Correlation-Id header and
the authenticated user, a message consumer copies them off the envelope. The sample’s commands carry the
values and the handler sets them.
3. The enricher — read the context, stamp the headers
public sealed class RequestContextEnricher(RequestContext context) : IEventMetadataEnricher
{
public void Enrich(IDictionary<string, object?> metadata, string commandName)
{
if (context.CorrelationId is { } c) metadata["CorrelationId"] = c;
if (context.CausationId is { } k) metadata["CausationId"] = k;
if (context.ActorId is { } a) metadata["ActorId"] = a;
if (context.TenantId is { } t) metadata["TenantId"] = t;
}
}
Only present values are written — an absent header simply isn’t stamped — and it never touches
CommandName/Timestamp (those are framework-owned and written last regardless).
4. The handler populates the context, then does ordinary work
public Task<Order> Handle(PlaceOrderCommand command, CancellationToken ct)
{
context.CorrelationId = command.CorrelationId;
context.CausationId = command.OrderId.ToString();
context.ActorId = command.ActorId;
context.TenantId = command.TenantId;
return Task.FromResult(Order.Place(command.OrderId, command.Sku, command.Quantity)); // tracked → appended on commit
}
5. Wire it — article 005’s stack plus two lines
services.AddDbContext<OrderingDbContext>((sp, o) => o.UseNpgsql(connectionString),
contextLifetime: ServiceLifetime.Scoped, optionsLifetime: ServiceLifetime.Singleton);
services.AddRelayEventStoreEfCore<OrderingDbContext>();
services.AddRelayUnitOfWorkEfCore<OrderingDbContext>();
services.AddRelayEventSourcedRepositoryEfCore();
services.AddScoped<RequestContext>(); // the per-command context …
services.AddScoped<IEventMetadataEnricher, RequestContextEnricher>(); // … and the enricher that reads it
services.AddRelay(typeof(OrderingFixture).Assembly);
Registering the IEventMetadataEnricher is the entire opt-in. The pipeline discovers it via
GetServices<IEventMetadataEnricher>(); no other wiring changes. (Map the relay tables onto the
DbContext exactly as in article 005: ApplyRelayEventStore() + ApplyRelaySnapshots().)
Complete source code
| File | Contents |
|---|---|
Ordering/Order.cs |
The event-sourced aggregate and its events (unchanged 005 shape) |
Ordering/Commands.cs |
Commands + handlers that populate the request context |
Ordering/RequestContext.cs |
The scoped, per-command ambient context |
Ordering/RequestContextEnricher.cs |
The custom IEventMetadataEnricher |
OrderingDbContext.cs |
ApplyRelayEventStore() + ApplyRelaySnapshots() |
OrderingFixture.cs |
Testcontainers Postgres + the production DI wiring (+ enricher) |
EventMetadataTests.cs |
Stamps every event; framework keys win; per-scope isolation |
Running the example
dotnet test samples/029-rich-event-metadata/Ordering.Metadata.Tests
This needs Docker — the fixture starts a real postgres:16 via Testcontainers, applies the schema
with EnsureCreatedAsync(), and runs the full stack. The metadata is a column on the stored event, so
the only honest demonstration is against a real store. In production you replace EnsureCreatedAsync()
with EF migrations (or the baseline SQL in libraries/nuvora-nexus-relay/docs/migrations/).
Testing
The sample’s EventMetadataTests.cs
proves the properties that make enriched metadata trustworthy, by reading the stored log back.
Every appended event carries the enriched headers:
var correlationId = $"corr-{Guid.NewGuid():N}";
await Bus.Execute<PlaceOrderCommand, Order>(new PlaceOrderCommand(id, "WIDGET-1", 3, correlationId, "user-42", "tenant-acme"), default);
await Bus.Execute<ChangeQuantityCommand, Order>(new ChangeQuantityCommand(id, 5, correlationId, "user-42", "tenant-acme"), default);
var events = await eventStore.GetEventsAsync(id, 0, default);
events.Should().OnlyContain(e => Header(e, "CorrelationId") == correlationId); // on BOTH events
events.Should().OnlyContain(e => Header(e, "ActorId") == "user-42");
Framework keys remain authoritative beside the custom ones — CommandName/Timestamp are always
present and never spoofed — and each command’s scope is isolated, so two commands stamp two distinct
correlation ids with no bleed-through. These are integration tests against real PostgreSQL because the
metadata only exists once the event is actually persisted; parsing event.Metadata off the stored row
is the genuine read path.
Production considerations
- Keep metadata small and flat. It is stored on every event, forever. A handful of short string ids is ideal; nested objects and blobs bloat the store and the log. If it’s big or queryable, it belongs in the payload or a projection, not metadata.
- Never put PII or secrets in metadata. Emails, names, tokens, card data — keep them out. Stamp an
opaque
ActorId/TenantIdand resolve the human details elsewhere at read time. Metadata, like the events it rides on, is effectively immutable and long-lived, so a leaked field is a lasting liability. When you genuinely must associate sensitive data with a stream, reach for crypto-shredding patterns (storing only an encrypted reference whose key can be destroyed) rather than plaintext in the envelope. - Make enrichers deterministic and side-effect-free. They run inside the commit path. Read from ambient context / config; do not call out to a database, an HTTP service, or anything that can block, throw, or vary — a slow or throwing enricher slows or fails every command. If a value needs a lookup, resolve it before the command and pass it through the context.
- The framework keys are reserved.
CommandNameandTimestampare written last and always win; don’t rely on an enricher to set them, and don’t expect to override them. - Set the context at the edge, exactly once. Populate correlation/actor/tenant in inbound middleware or the message-consumer envelope so all downstream commands inherit it, rather than threading it through every command by hand (the sample threads it explicitly only to stay self-contained).
Common mistakes
- Putting tracing context in the event payload. Adding
UserId/TraceIdfields to a business event couples the domain to plumbing and pollutes the immutable, forever-replayed payload. That context is metadata; keep the event a pure business fact. - Expensive or throwing enrichers. A database call or a flaky HTTP lookup inside
Enrichruns on the commit path of every command — it serializes into latency and turns a transient dependency blip into a failed write. Enrichers read ambient state only. - Relying on metadata for business decisions. Metadata can be ignored, dropped by an old reader, or absent on legacy events. If a handler/projection must act on a value, it is a business fact — put it in the payload.
- Reading the wrong scope. If the enricher resolves a context from a different scope than the handler populated, it stamps stale or empty values. Register the context scoped and set it inside the command’s handler (or edge middleware that shares the scope).
- Stamping PII “just in case.” Tempting and convenient, permanent and regrettable. Stamp ids, not identities.
Tradeoffs
Benefits. A complete audit/tracing envelope on every event with zero domain changes; correlation and causation that reconstruct distributed flows from the stored log; tenant stamping that travels with each fact; all via a single cross-cutting DI registration that the aggregate and handlers never see.
Costs. A little extra storage on every event (forever); an enricher that must stay cheap and side-effect-free because it runs in the commit path; the discipline to keep metadata small and free of PII; and the fact that metadata is advisory (readers may ignore it), so it must not carry anything a decision depends on.
Alternatives
- Default metadata only.
CommandName+Timestamp, no enrichers. Correct when you never need to trace or audit by actor/tenant — the simplest choice; add enrichers only when a question demands them. - Context in the event payload. Put
CorrelationId/ActorIddirectly on the domain event. Makes it a hard business field (good if a decision truly depends on it) but couples the domain to plumbing and bloats every payload version; usually the wrong home for cross-cutting context. - A separate audit-log table. Write a parallel “who did what” row. A lossy, hand-maintained shadow that drifts from the events (the same trap article 005 describes for state). Metadata on the event stream is the events’ own audit trail.
- External tracing systems (OpenTelemetry). Spans and traces are excellent for live request flow and latency, but they expire and are sampled. Event metadata is durable and exact — they’re complementary: the correlation id you stamp is the same one your traces carry (article 021).
Lessons from production systems
- The first incident teaches you what you forgot to stamp. Teams that ship with
{CommandName, Timestamp}only, then spend a war-room reconstructing “who triggered this and as part of what,” invariably add an actor + correlation enricher the next day. Stamping them from day one is cheap insurance. - Enrichers that call out are a latent outage. The enricher that did a quick DB lookup “to enrich nicely” is the one that took the write path down when that DB hiccuped. Keep them pure; do lookups before the command.
- PII in metadata is a compliance headache that compounds. Because events live forever, an email stamped today is an email you must account for in every export and deletion request for years. The teams that stamp only opaque ids sleep better.
Should you use this?
| If you… | Then… |
|---|---|
| audit or regulate (need “who did what, when”) | yes — stamp ActorId (and tenant); the stream becomes the audit log |
| trace distributed flows across services | yes — stamp correlation + causation ids |
| run multi-tenant on a shared store | yes — stamp TenantId so each fact carries its owner |
are happy with CommandName + Timestamp |
not yet — add an enricher when a real question demands it |
| want to stamp PII / large data / queryable fields | no — opaque ids only; payload/projection for the rest |
Next steps
- 005 — Event Sourcing Basics: the append/replay foundation this
builds on — how events become the source of truth and how
GetEventsAsyncreads them back. - 021 — Observability: the correlation id you stamp here is the same one your traces, logs, and metrics carry — durable event metadata and live telemetry are two halves of the same picture.