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.
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
ChangeTrackerYou need to capture business intent, not just field mutations. A property change from
Status = PendingtoStatus = Approvedis captured as a raw field update; the fact that it represents an approval decision by a specific role is notYou use soft deletes widely โ the interceptor captures the
IsDeletedfield flip, not a semantically meaningful "deleted" eventYour 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
ApprovalStatustotrue")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:
EF Core interceptor as the default โ captures all data mutations automatically with transactional consistency. This is the always-on safety net.
Domain events for high-value business operations โ
OrderPlaced,UserSuspended,PermissionGrantedโ where compliance or legal teams need interpretable records in business language.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.






