Command audit
Every command into an aggregate passes through the ICommandMiddleware pipeline. That's the seam where you record who issued what, where it came from, and how it landed — typically alongside HTTP request context like the authenticated user, the request ID, and the source IP.
Nagare ships the seam, not the audit log itself. The pattern is small, host-controlled, and fits anywhere you'd otherwise be tempted to scatter _logger.LogInformation calls.
The middleware seam
public interface ICommandMiddleware
{
Task<IReply> InvokeAsync(AskContext context, AskDelegate next);
}
public sealed record AskContext
{
public required AggregateId AggregateId { get; init; }
public required object Command { get; init; }
public IEventMetadata? Metadata { get; init; }
public IReadOnlyDictionary<string, object> Properties { get; init; }
}The Metadata is your EventMetadata (ActorId, CorrelationId, TraceParent, CausationId, Headers, …) — anything you put there ends up persisted alongside every event the command produces. ActorId is the stable id of whoever asked (a user id for HTTP requests, a job name for the scheduler, a migration tag for system tasks); Headers is a free-form IReadOnlyDictionary<string, string> for things you want to surface in projections and dashboards but don't have a first-class field — tenant id, branch, feature-flag bucket.
The Properties bag is for middleware-to-middleware passing within a single Ask. Use it for IP, route, claims — values you want for audit but don't want pinned into the event metadata for every event.
Recipe — HTTP-aware command audit
The canonical setup for an ASP.NET app: pull from HttpContext once at the boundary, pin into AskContext, write the audit row at the end of the call.
public sealed class HttpContextCommandMiddleware(IHttpContextAccessor http) : ICommandMiddleware
{
public Task<IReply> InvokeAsync(AskContext context, AskDelegate next)
{
var ctx = http.HttpContext;
if (ctx is null) return next(context);
// Pin actor identity into EventMetadata so it persists on every emitted event.
var actorId = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
var correlationId = ctx.TraceIdentifier;
var traceParent = Activity.Current?.Id;
var tenantId = ctx.User.FindFirstValue("tenant_id");
var enriched = context with
{
Metadata = (context.Metadata as EventMetadata ?? EventMetadata.Empty) with
{
ActorId = actorId,
CorrelationId = correlationId,
TraceParent = traceParent,
Headers = tenantId is null
? null
: new Dictionary<string, string> { ["tenant"] = tenantId },
}
};
// Pin transient request context into Properties so the audit-writing
// middleware downstream can use it without rebuilding it from the request.
enriched = enriched
.WithProperty("ip", ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown")
.WithProperty("route", ctx.Request.Path.Value ?? "")
.WithProperty("method", ctx.Request.Method);
return next(enriched);
}
}public sealed class AuditWriterMiddleware(IAuditLog audit) : ICommandMiddleware
{
public async Task<IReply> InvokeAsync(AskContext context, AskDelegate next)
{
var startedAt = DateTimeOffset.UtcNow;
var reply = await next(context);
var outcome = reply switch
{
Replies.Accepted => "accepted",
Replies.Ignored => "ignored",
IRejection r => $"rejected: {r.Reason.Message}",
_ => "unknown",
};
await audit.Write(new AuditEntry(
At: startedAt,
CommandType: context.Command.GetType().Name,
AggregateId: context.AggregateId.Value,
ActorId: (context.Metadata as EventMetadata)?.ActorId,
Ip: context.Properties.GetValueOrDefault("ip") as string,
Route: context.Properties.GetValueOrDefault("route") as string,
Outcome: outcome,
DurationMs: (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds));
return reply;
}
}builder.Services.AddHttpContextAccessor();
builder.Services.AddCommandMiddleware<HttpContextCommandMiddleware>();
builder.Services.AddCommandMiddleware<AuditWriterMiddleware>();
builder.Services.AddSingleton<IAuditLog, RelationalAuditLog>();Why a host-side IAuditLog, not a Nagare-side one
Two reasons we deliberately don't ship a packaged Nagare.Audit:
- You want control over the schema and indexes. Audit access patterns vary wildly — some teams query by user, some by aggregate, some by time range, some join against an HR table. The right index set is yours to choose.
- Payload shape is yours. A finance app needs
CurrencyandAmountcolumns audited; a healthcare app needsPatientRefseparated for redaction. A generic shape would drag every host into a column-mapping fight.
The middleware seam is the contract; the table is yours.
Audit at the event level (not just commands)
Commands describe intent (AddBook, CancelReservation). Events describe what happened (BookAdded, ReservationCancelled).
A command-side audit catches every attempt, including rejections. An event-side audit catches every commit, with full payloads. Most teams want both.
For event audit, write a ISubscription<TEvent> and use IEventExplainer<TEvent> to turn the event into prose:
public sealed class AuditLogProjection(IAuditLog audit, IEventExplainer<BookEvent> explainer)
: ISubscription<BookEvent>
{
public SubscriptionId SubscriptionId => new("audit-book-events");
public async Task Handle(EventEnvelope<BookEvent> envelope)
{
var explanation = explainer.Explain(envelope);
await audit.Write(new AuditEntry(
At: envelope.CreatedAt,
EventType: envelope.Event.GetType().Name,
StreamId: envelope.AggregateId.Value,
Position: envelope.Position,
Title: explanation.Title,
Summary: explanation.Summary,
ActorId: envelope.Metadata?.ActorId,
CorrelationId: envelope.Metadata?.CorrelationId));
}
}See Event explainers for the explainer contract.
Audit at the projection level (downstream effects)
A subscription that updates a read model can write its own audit row when it does. The two-stage audit_command → audit_event → audit_projection_apply chain lets you reconstruct any change end to end:
"User
aliceranBorrowBookonbook-2at 18:32 → producedBookBorrowedat position 4798 → which updated theBorrowedBooksread model row formember-0042."
Each link in that chain is one row in your audit table, joinable on correlation_id.
Anti-patterns
- Don't put HttpContext into
EventMetadata. Metadata persists with every event forever. IP addresses and request IDs are operational, not historical — they belong in your audit table, not your event store. - Don't audit inside the command handler. Handlers are pure; side effects belong in middleware.
- Don't use the audit log as a replay source. Events are the source of truth; the audit log is a read-side artefact. Don't recompute aggregate state from it.
- Don't audit rejections only when convenient. A rejected command often is the audit signal you most need ("user X tried to borrow book Y while suspended"). The middleware seam catches them by default — keep it that way.
See also
- Event explainers — turning typed events into prose for the audit summary column
- Middleware — the full pipeline contract and ordering rules