Skip to main content

Command Palette

Search for a command to run...

Audit Logging in ASP.NET Core: Middleware vs EF Core Interceptors vs Domain Events โ€” Enterprise Decision Guide

Updated
โ€ข14 min read
Audit Logging in ASP.NET Core: Middleware vs EF Core Interceptors vs Domain Events โ€” Enterprise Decision Guide

Audit logging is one of those requirements that sounds simple until you actually sit down to implement it across a production API. Every enterprise team eventually faces the same question: where does the audit logic live, and what captures it? The answer affects maintainability, compliance coverage, performance, and how painful debugging becomes when something goes wrong six months later.

Three approaches dominate audit logging strategy in ASP.NET Core APIs: request-level middleware that captures HTTP traffic, EF Core SaveChanges interceptors that capture data mutations at the persistence layer, and domain event handlers that capture business-meaningful state changes from within the domain model. Each has a rightful place โ€” and each is the wrong choice in the wrong context. The full working implementation of each approach, including the audit entity schema, scoped service wiring, and a complete test suite, is available on Patreon for teams who want production-ready code alongside the theory.

Understanding Ch 12 of the Zero to Production course walks through audit trail implementation inside a complete ASP.NET Core API, showing how SaveChanges interceptors and domain events work together within the same production codebase โ€” with the infrastructure already wired in.

ASP.NET Core Web API: Zero to Production


What Audit Logging Is Actually Solving

Before selecting an approach, it is worth being precise about what you are trying to capture. Enterprise teams typically need audit logging for at least one of three reasons:

  • Compliance and regulatory mandates โ€” GDPR, HIPAA, SOC 2, PCI-DSS, and similar frameworks require demonstrable records of who accessed or changed what, and when

  • Operational debugging โ€” reconstructing what a user did immediately before a data integrity problem surfaced

  • Security forensics โ€” detecting and investigating suspicious access patterns or privilege escalation after the fact

The approach you choose should match the audit signal you actually need. HTTP-level signals are fundamentally different from persistence-level signals, which are different again from business-intent signals. Conflating them leads to audit logs that look impressive and help no one.


Approach 1: Request-Level Middleware

What It Captures

ASP.NET Core middleware sits at the HTTP pipeline level. A custom middleware component can intercept every inbound request and outbound response, capturing the actor identity (from HttpContext.User), the endpoint called, the HTTP verb, the request timestamp, the response status code, and optionally the request body.

Middleware audit logging answers: who called what, when, and what did the API say back?

When Middleware Is the Right Choice

Middleware audit logging is appropriate when:

  • You need an HTTP access log independent of whether a database operation succeeded

  • Your compliance requirement is specifically about API endpoint access, not data mutation (common in healthcare and financial services where access logs are mandated separately from change logs)

  • You are auditing endpoints that use raw SQL, external service calls, or caching โ€” where no EF Core interaction occurs

  • You need to capture requests that fail validation and never reach the service layer at all

  • Your team wants a consistent audit record regardless of which persistence strategy each endpoint uses

A well-implemented middleware audit log captures the full HTTP story. It does not care whether the underlying operation used EF Core, Dapper, or a third-party API.

When Middleware Is Not the Right Choice

Middleware becomes the wrong tool when:

  • You need field-level change tracking (which property changed from what value to what value)

  • You are auditing batch operations that issue multiple database writes per HTTP request

  • You need to distinguish between business operations at a finer grain than HTTP routes provide

  • Your audit store requires transactional consistency with the data change (middleware writes happen outside your database transaction)

The transactional consistency limitation is the most significant. If a middleware component writes the audit record to its own table and the downstream database write then fails and rolls back, you have an audit log entry for an operation that never actually completed. That kind of phantom audit entry can create compliance problems as serious as the missing records it was meant to prevent.


Approach 2: EF Core SaveChanges Interceptors

What It Captures

EF Core interceptors hook into the SaveChanges pipeline. A SaveChangesInterceptor implementation receives the ChangeTracker before persistence, exposing every entity being inserted, updated, or deleted โ€” including the old and new property values for updates.

The ISaveChangesInterceptor interface provides both synchronous and asynchronous interception points, and because it runs inside the same database transaction as your domain changes, the audit record either commits with the data or rolls back with it.

EF Core interceptors answer: what changed in the database, which entity was affected, and what were the before/after values?

When EF Core Interceptors Are the Right Choice

Interceptors are the dominant choice for enterprise audit logging when:

  • You need field-level change history with old and new values (mandatory for financial systems, healthcare records, and many GDPR scenarios)

  • Transactional consistency between the audit record and the data change is a hard requirement

  • Most or all of your persistence goes through EF Core (which is true for the majority of ASP.NET Core APIs)

  • You want a low-friction, automatic capture that works across all entities without modifying individual service methods

  • You are implementing a general-purpose audit trail for all data mutations, not a curated set of business events

The ChangeTracker gives interceptors complete visibility into what EF Core is about to commit. Combined with ICurrentUserService (or similar) for resolving the actor identity, an interceptor can populate a normalized AuditLog table with virtually no changes to existing business logic.

One important design detail: interceptors run inside the request scope and have access to scoped DI services โ€” but only if you register them correctly. Registering an interceptor that captures a scoped service as a singleton is one of the most common sources of DbContext disposal errors in production audit implementations.

When EF Core Interceptors Are Not the Right Choice

Interceptors fall short when:

  • Your API uses Dapper or raw SQL for performance-critical paths โ€” those writes are invisible to the ChangeTracker

  • You need to capture business intent, not just field mutations. A property change from Status = Pending to Status = Approved is captured as a raw field update; the fact that it represents an approval decision by a specific role is not

  • You use soft deletes widely โ€” the interceptor captures the IsDeleted field flip, not a semantically meaningful "deleted" event

  • Your audit requirement is for access patterns (who viewed a record), not just mutations โ€” EF Core read operations do not pass through SaveChanges

The coverage gap matters. If a significant portion of your data access bypasses EF Core, interceptors give you a partial audit log that may appear complete but silently misses entire categories of writes.


Approach 3: Domain Event Handlers

What It Captures

Domain events express what happened in terms the business actually cares about: OrderApproved, UserRoleEscalated, PaymentRefunded, AccountSuspended. When these events are raised by domain entities and handled by dedicated audit handlers, the audit log captures business intent rather than raw data mutations.

Domain event audit handlers answer: what business decision was made, by whom, in what context, and what was the outcome?

When Domain Events Are the Right Choice

Domain events are the superior choice for audit logging when:

  • Your compliance requirement demands audit records at the business operation level (common in regulated industries where regulators want to know "who approved this loan" not "who set ApprovalStatus to true")

  • You are operating in a domain-rich model (DDD) where entities already raise events for state transitions

  • You need audit entries that are interpretable by non-technical reviewers (compliance officers, auditors, legal teams)

  • Your audit records feed downstream systems โ€” event sourcing, SIEM platforms, external audit repositories โ€” that expect business-level event payloads

  • You use MediatR pipeline behaviors and already have a cross-cutting audit behavior in place

Domain events decouple the audit concern from both HTTP and persistence infrastructure. The audit handler is just another event consumer โ€” it can write to a dedicated audit store, a separate database, an event stream, or a compliance platform without touching the business logic that raised the event.

When Domain Events Are Not the Right Choice

Domain events require investment to be effective. They are not the right primary audit strategy when:

  • Your codebase uses an anemic domain model where entities are passive data containers โ€” there are no events to raise

  • You need comprehensive coverage of all data mutations, not just curated business state transitions

  • You are working with a legacy codebase where introducing domain events requires broad refactoring

  • Field-level change history is required โ€” domain events typically carry intent and outcome, not property-level diffs

Relying solely on domain events also creates an inherent coverage risk: a developer adds a new write path without raising the corresponding event, and that write is silently excluded from the audit trail. Interceptors, by contrast, capture everything that flows through EF Core automatically.


Decision Matrix: Which Approach for Which Requirement?

Requirement Middleware EF Core Interceptors Domain Events
HTTP access log (who called what) โœ… Primary choice โŒ No signal โŒ No signal
Field-level change tracking โŒ No visibility โœ… Primary choice โš ๏ธ Only if events carry diffs
Transactional consistency with data โŒ Outside transaction โœ… Same transaction โš ๏ธ Depends on handler design
Business intent / semantics โŒ HTTP-only context โš ๏ธ Raw field changes only โœ… Primary choice
Dapper / raw SQL coverage โœ… Always captures HTTP โŒ No coverage โš ๏ธ Requires manual event raise
Failed request capture โœ… Always โŒ No SaveChanges โŒ No event raised
GDPR right-to-erasure support โš ๏ธ Actor access log only โœ… Full entity coverage โš ๏ธ Business level only
Non-technical audit reader โŒ Technical HTTP data โŒ Raw property diffs โœ… Business language
Low implementation friction โš ๏ธ Medium โœ… Low โš ๏ธ High (needs domain model)

The Layered Strategy: Why Most Production Systems Use Two

The most robust enterprise audit implementations combine approaches rather than picking one. A practical layered strategy for most ASP.NET Core APIs looks like:

  1. EF Core interceptor as the default โ€” captures all data mutations automatically with transactional consistency. This is the always-on safety net.

  2. Domain events for high-value business operations โ€” OrderPlaced, UserSuspended, PermissionGranted โ€” where compliance or legal teams need interpretable records in business language.

  3. Middleware for access auditing โ€” separate from data mutation auditing, handles the "who viewed this endpoint" requirement when regulatory mandates require it.

This layered approach means no single audit mechanism is a single point of failure. If a domain event is accidentally omitted from a new write path, the interceptor still captures the underlying data change. If the EF Core path is bypassed, the HTTP access log still records the attempted operation.


Anti-Patterns to Avoid

Logging audit records outside a transaction. Writing audit entries to a separate table in a different database context (or a different service call entirely) creates phantom audit entries when the main transaction rolls back. Always co-locate your EF Core audit writes in the same DbContext or use a transactional outbox if the audit store is external.

Logging full request bodies everywhere. HTTP middleware that captures raw request bodies will capture PII, passwords, and payment card data unless you apply sensitive field masking. GDPR and PCI-DSS make this a compliance liability, not a feature.

Registering scoped services as singletons in interceptors. EF Core interceptors registered as singletons cannot safely inject scoped services like ICurrentUserService. Use AddInterceptors() at AddDbContext() time with proper scoping, or resolve the current user from IHttpContextAccessor injected at the correct lifetime.

Treating structured logging as an audit log. Application logs (Serilog, OpenTelemetry traces) are for operational visibility. Audit logs are a legal artifact. Using the same storage, format, and retention policy for both is a governance failure. Structured logs rotate and are pruned; audit records must be immutable and retained for defined regulatory periods.


What to Adopt Right Now

For teams building a new ASP.NET Core API or auditing an existing one:

  • Start with an EF Core SaveChanges interceptor โ€” it gives immediate, broad coverage with minimal code change

  • Add domain events for the 5โ€“10 business operations your compliance team actually asks about in audit reviews

  • Add middleware HTTP access logging only if your security or compliance requirement explicitly demands endpoint-level access records

  • Review your audit record retention policy and make sure audit tables are protected from application-layer deletes

โ˜• If this decision guide saved you time, buy us a coffee โ€” it helps keep content like this coming.


FAQ

What is the difference between audit logging and application logging in ASP.NET Core? Application logging (via Serilog, ILogger, OpenTelemetry) records operational events for debugging and observability โ€” log levels rotate, data is pruned, and the format is optimised for developer tooling. Audit logging records legally significant changes to data or access by users โ€” records must be immutable, tamper-evident, and retained for defined compliance periods. Storing both in the same sink under the same retention policy is a compliance risk.

Can EF Core interceptors audit Dapper or raw SQL queries? No. EF Core's SaveChangesInterceptor only observes operations flowing through the EF Core DbContext. Writes executed via Dapper, FormattableString raw SQL outside of EF Core, or direct ADO.NET calls are invisible to the change tracker. If your codebase mixes EF Core with Dapper, your interceptor audit log will be incomplete unless you add explicit audit writes in the Dapper code paths.

Should audit logs be stored in the same database as application data? For most mid-size APIs, co-locating audit tables in the same database is practical and makes transactional consistency straightforward. For high-compliance scenarios (financial, healthcare), a dedicated audit database with write-only application access โ€” so the application can insert but not update or delete audit rows โ€” provides stronger tamper-evidence. The tradeoff is operational complexity.

How do I capture the current user identity inside an EF Core interceptor? Inject IHttpContextAccessor into the interceptor (resolving it from the service provider at AddDbContext time, not as a singleton property). Access httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier) to retrieve the authenticated user ID. If the interceptor runs in a background job context where there is no HTTP context, fall back to a dedicated ICurrentUserService that can return a system identity for non-request-scoped operations.

What fields should every audit log record include? At minimum: entity type, entity primary key, operation type (Insert/Update/Delete), actor identity (user ID or system identity), timestamp (UTC), the changed properties with old and new values (for updates), and a correlation or request ID that ties the audit entry back to the HTTP request. For higher compliance requirements, add the source IP address, session identifier, and a hash of the record for tamper detection.

Is there a performance cost to EF Core SaveChanges interceptors? Yes, but it is typically small relative to the network round-trip cost of the database write itself. The interceptor traverses the change tracker, which is already populated before SaveChanges. The dominant cost is the additional INSERT into the audit table โ€” keep audit writes in the same transaction and batch them when multiple entities change in a single SaveChanges call. Avoid serialising full object graphs to JSON in the hot path; capture only the changed properties.

Do domain events work for audit logging in a CQRS / MediatR setup? Yes, and this is one of the cleaner integration points. A MediatR pipeline behavior can intercept all commands, extract the actor and intent, and publish an audit event after the command handler succeeds. This gives you a consistent audit record for every state-changing command without modifying individual handlers. Combine it with an EF Core interceptor for the field-level detail and you have both business-intent and data-level coverage in the same system.

More from this blog

C

Coding Droplets

227 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.