Skip to content

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

csharp
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.

csharp
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);
    }
}
csharp
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;
    }
}
csharp
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:

  1. 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.
  2. Payload shape is yours. A finance app needs Currency and Amount columns audited; a healthcare app needs PatientRef separated 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:

csharp
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_commandaudit_eventaudit_projection_apply chain lets you reconstruct any change end to end:

"User alice ran BorrowBook on book-2 at 18:32 → produced BookBorrowed at position 4798 → which updated the BorrowedBooks read model row for member-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

流れ — flow.