Back to KB
Difficulty
Intermediate
Read Time
9 min

I built a NuGet middleware to catch N+1 problems and slow queries in ASP.NET Core

By Codcompass Team··9 min read

EF Core Query Telemetry: Intercepting N+1 Patterns at the Middleware Layer

Current Situation Analysis

Entity Framework Core abstracts database interactions behind a fluent API, which accelerates development but introduces a silent performance tax: the N+1 query problem. When an application fetches a parent collection and subsequently accesses a navigation property without explicit eager loading, the ORM generates one initial query plus one additional query per parent entity. In a dataset of 50 items, this translates to 51 round trips instead of 1. At 40ms per query, the latency jumps from ~40ms to ~2,040ms. The database connection pool saturates, thread pool starvation follows, and the application degrades under load.

This pattern is notoriously difficult to catch during development. Local environments typically run against small, warm caches or development databases with negligible latency. Developers rarely notice the query explosion until the application reaches staging or production, where network latency, connection pooling limits, and concurrent traffic amplify the issue. Traditional Application Performance Monitoring (APM) tools detect elevated endpoint latency but rarely correlate it back to specific ORM anti-patterns without expensive distributed tracing configurations. Manual profiling requires attaching debuggers or external SQL profilers, which breaks the development flow and is impractical for continuous integration pipelines.

The core misunderstanding lies in treating query performance as a production monitoring problem rather than a development-time contract. ORM behavior should be validated at the boundary of each HTTP request, with immediate feedback that maps directly to the executing controller or endpoint. Without request-scoped telemetry, developers are left guessing which navigation property triggered the cascade, often resorting to trial-and-error .Include() chains that bloat memory and transfer unnecessary data.

WOW Moment: Key Findings

Shifting query diagnostics from post-deployment monitoring to request-scoped interception changes how teams catch ORM anti-patterns. By instrumenting the EF Core command pipeline and correlating execution metrics with the active HTTP context, you gain deterministic visibility into query behavior before it ever reaches a load balancer.

ApproachDetection GranularitySetup OverheadProduction SafetyReal-time Feedback
Traditional APM TracingEndpoint-level latencyHigh (agents, dashboards, sampling)Safe (aggregated)Delayed (minutes to hours)
Manual SQL ProfilingQuery-levelMedium (external UI, debugger attachment)Unsafe (dev-only, high overhead)Immediate
Request-Scoped InterceptorQuery-per-requestLow (DI registration, middleware pipeline)Safe (configurable thresholds)Immediate

This finding matters because it decouples performance debugging from infrastructure dependencies. You no longer need a separate observability stack to catch N+1 patterns. The interceptor captures execution metadata, fingerprints normalized SQL to detect repetition, and flushes diagnostics when the request completes. The result is a zero-dashboard, zero-agent feedback loop that runs entirely within the development terminal. It enables teams to enforce query budgets per endpoint, catch lazy-loading regressions in code reviews, and establish baseline performance contracts before merging.

Core Solution

Building a request-scoped query auditor requires three architectural components: an EF Core command interceptor, a request-scoped diagnostic context, and a middleware pipeline that orchestrates lifecycle management. The design prioritizes isolation, configurability, and zero business-logic intrusion.

Step 1: Define the Diagnostic Context

The context holds per-request metrics. It must be scoped to the HTTP request to prevent cross-contamination between concurrent users.

public sealed class QueryAuditContext
{
    public string EndpointName { get; set; } = string.Empty;
    public List<QueryExecutionRecord> Executions { get; } = new();
    public int SlowQueryCount { get; private set; }
    public int NPlusOneCount { get; private set; }

    public void RecordExecution(string sql, TimeSpan duration, bool isSlow)
    {
        Executions.Add(new QueryExecutionRecord(sql, duration, isSlow));
        if (isSlow) SlowQueryCount++;
    }

    public void FlagNPlusOne(string fingerprint, int occurrences)
    {
        NPlusOneCount++;
        // Store for later reporting
    }
}

public record QueryExecutionRecord(string Sql, TimeSpan Duration, bool IsSlow);

Step 2: Implement the EF Core Interceptor

EF Core 8+ uses ICommandInterceptor. We'll capture reader execution to measure duration and extract SQL. Query fingerprinting normalizes parameterized queries to detect repetition.

public sealed class EfCoreQueryAuditor : ICommandInterceptor
{
    private readonly QueryAuditContext _context;
    private readonly DiagnosticOptions _options;

    public EfCoreQueryAuditor(QueryAuditContext context, DiagnosticOptions options)
    {
        _context = context;
        _options = options;
    }

    public DbCommand ReaderExecuting(DbCommand command, CommandEventData eventData, DbCommand result)
    {
        command.StartTime = DateTime.UtcNow;
        return result;
    }

    public DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
    {
        var duration = DateTime.UtcNow - command.StartTime;
        var isSlow = duration.TotalMilliseconds >= _options.SlowQueryThresholdMs;
        
        _context.RecordExecution(command.CommandText, duration, isSlow);
        return result;
    }

    public async ValueTask<DbDataReader> ReaderExecutedAsync(
        DbCommand command, CommandExecutedEventData eventData, DbDataReader result, CancellationToken cancellationToken)
    {
        var duration = DateTime.UtcNow - command.StartTime;
        var isSlow = duration.TotalMilliseconds >= _options.SlowQueryThresholdMs;
        
        _context.RecordExecution(command.CommandText, duration, isSlow);
        return result;
    }
}

// Extension to attach timing without modifying DbCommand
public static class DbCommandExtensions
{
    private static readonly AsyncLocal<DateTime?> _startTime = new();
    public static DateTime StartTime 
    { 
        get => _startTime.Value ?? DateTime.MinValue; 
        set => _startTime.Value = value; 
    }
}

Step 3: Build the Middleware Pipeline

The middleware initializes the audit context, sets the endpoint identifier, and flushes diagnostics on request completion. It groups executions by fingerprint to detect N+1 patterns.

public sealed class QueryDiagnosticMiddleware
{
    private readonly RequestDelegate

_next; private readonly DiagnosticOptions _options; private readonly ILogger<QueryDiagnosticMiddleware> _logger;

public QueryDiagnosticMiddleware(RequestDelegate next, DiagnosticOptions options, ILogger<QueryDiagnosticMiddleware> logger)
{
    _next = next;
    _options = options;
    _logger = logger;
}

public async Task InvokeAsync(HttpContext context, QueryAuditContext auditContext)
{
    auditContext.EndpointName = context.GetEndpoint()?.DisplayName ?? "Unknown";
    
    await _next(context);

    AnalyzeAndReport(auditContext);
}

private void AnalyzeAndReport(QueryAuditContext context)
{
    var fingerprints = context.Executions
        .GroupBy(e => NormalizeFingerprint(e.Sql))
        .Where(g => g.Count() > 1)
        .ToList();

    foreach (var group in fingerprints)
    {
        if (group.Count() >= _options.NPlusOneThreshold)
        {
            _logger.LogWarning(
                "⚠️ N+1 pattern detected at {Endpoint}. Query repeated {Count}x. Sample: {Sql}",
                context.EndpointName, group.Count(), group.First().Sql[..Math.Min(80, group.First().Sql.Length)]);
        }
    }

    var slowQueries = context.Executions.Where(e => e.IsSlow);
    foreach (var query in slowQueries)
    {
        _logger.LogWarning(
            "⚠️ Slow query at {Endpoint}. Duration: {Duration}ms. SQL: {Sql}",
            context.EndpointName, query.Duration.TotalMilliseconds, query.Sql[..Math.Min(80, query.Sql.Length)]);
    }
}

private static string NormalizeFingerprint(string sql)
{
    // Strip parameters, collapse whitespace, uppercase keywords
    return Regex.Replace(sql, @"@[\w]+", "@p")
                .Replace("\r", " ").Replace("\n", " ")
                .ToUpperInvariant();
}

}


### Step 4: Wire into DI and Middleware
Registration ties the scoped context, interceptor, and middleware together.

```csharp
public static class DiagnosticServiceCollectionExtensions
{
    public static IServiceCollection AddQueryDiagnostics(this IServiceCollection services, Action<DiagnosticOptions>? configure = null)
    {
        var options = new DiagnosticOptions();
        configure?.Invoke(options);
        services.AddSingleton(options);
        services.AddScoped<QueryAuditContext>();
        services.AddScoped<EfCoreQueryAuditor>();
        return services;
    }
}

public static class DiagnosticApplicationBuilderExtensions
{
    public static IApplicationBuilder UseQueryDiagnostics(this IApplicationBuilder app)
    {
        return app.UseMiddleware<QueryDiagnosticMiddleware>();
    }
}

public record DiagnosticOptions
{
    public int SlowQueryThresholdMs { get; init; } = 500;
    public int NPlusOneThreshold { get; init; } = 3;
}

Architecture Rationale

  • Request-Scoped Context: Prevents metric bleeding between concurrent requests. AsyncLocal is avoided for the main context to maintain explicit DI lifecycle control.
  • Interceptor Over Middleware: EF Core's command pipeline is the only reliable place to capture actual SQL execution timing. Middleware alone cannot measure database round-trip duration accurately.
  • Fingerprint Normalization: Parameterized queries differ by value but share structure. Normalizing @id1, @id2 to @p enables accurate N+1 detection without false negatives.
  • Deferred Reporting: Metrics are collected during execution and reported after await _next(context). This ensures the full request lifecycle is captured before analysis.

Pitfall Guide

1. Interceptor Lifecycle Mismatch

Explanation: Registering the interceptor as a singleton while the audit context is scoped causes cross-request state corruption. The interceptor will write to a stale or shared context. Fix: Always register the interceptor as Scoped or resolve it via serviceProvider.GetRequiredService<T>() during AddDbContext configuration. Match the context lifecycle exactly.

2. Threshold Calibration Neglect

Explanation: The default 500ms threshold assumes a local development database. In cloud environments or high-latency networks, legitimate queries may exceed this, generating noise. Conversely, strict thresholds may miss subtle N+1 cascades. Fix: Baseline your database latency under realistic load. Set SlowQueryThresholdMs to 1.5x your p95 network round-trip time. Use environment-specific configuration to adjust thresholds per deployment stage.

3. Parameterized Query Masking

Explanation: N+1 detection fails if the fingerprinting logic doesn't normalize parameters. Queries like WHERE Id = @id1 and WHERE Id = @id2 appear distinct, bypassing repetition detection. Fix: Implement robust SQL normalization that strips parameter names, collapses whitespace, and standardizes casing. Consider using a lightweight SQL parser for complex queries, or rely on EF Core's CommandText which already parameterizes values.

4. Cross-Request State Leakage via AsyncLocal

Explanation: Using AsyncLocal<T> for the audit context without explicit cleanup can leak state across request boundaries in high-throughput scenarios, especially when thread pool recycling occurs. Fix: Prefer DI-scoped services over AsyncLocal for request state. If AsyncLocal is unavoidable, implement IDisposable on the middleware to explicitly clear the context after request completion.

5. Production Log Noise

Explanation: Leaving diagnostic logging enabled in production floods log aggregators with warnings, increases I/O overhead, and obscures genuine errors. Fix: Gate diagnostic output behind a configuration flag or environment check. Implement sampling strategies (e.g., log only 10% of requests) or disable entirely in production, relying on APM for post-deployment visibility.

6. Ignoring Bulk Operation Legitimacy

Explanation: Batch inserts or bulk updates naturally execute multiple commands. Flagging them as N+1 creates false positives and erodes developer trust in the tool. Fix: Add a whitelist mechanism for known bulk operation patterns. Alternatively, track command types (INSERT, UPDATE, DELETE) and only flag SELECT repetition as N+1 candidates.

7. Connection Pool Starvation Blindness

Explanation: The interceptor measures query count and duration but doesn't correlate with connection pool metrics. An N+1 pattern might appear "fast" locally but exhaust pool connections under concurrency. Fix: Integrate with Microsoft.Extensions.Diagnostics.ResourceMonitoring or emit custom metrics to OpenTelemetry. Correlate query counts with SqlConnectionPool wait times to surface pool exhaustion risks early.

Production Bundle

Action Checklist

  • Register QueryAuditContext and EfCoreQueryAuditor as scoped services in Program.cs
  • Attach the interceptor to DbContextOptions using AddInterceptors()
  • Place UseQueryDiagnostics() early in the middleware pipeline, after routing but before authentication
  • Configure environment-specific thresholds via appsettings.{Environment}.json
  • Implement SQL fingerprint normalization that handles parameterized queries
  • Add conditional logging guards to disable diagnostics in production builds
  • Validate interceptor lifecycle matches context scope to prevent state leakage
  • Correlate diagnostic output with OpenTelemetry traces using Activity.Current?.Id

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
Local DevelopmentFull request-scoped interception with console warningsImmediate feedback, zero infrastructure, catches N+1 before commitNone
Staging/CI PipelineInterceptor enabled with threshold lowered to 200ms + JSON log outputAutomated regression detection, integrates with test runnersMinimal CI overhead
Production (High Traffic)Disable interceptor, rely on APM sampling + connection pool metricsEliminates per-request overhead, prevents log noiseZero runtime cost
Production (Debugging)Enable interceptor with 5% sampling rate + correlation IDsTargeted diagnostics without full overheadLow I/O cost
Microservices ArchitecturePer-service interceptor + centralized log aggregationIsolates ORM behavior per bounded context, avoids cross-service tracing complexityModerate logging cost

Configuration Template

{
  "QueryDiagnostics": {
    "Enabled": true,
    "SlowQueryThresholdMs": 500,
    "NPlusOneThreshold": 3,
    "LogOutputFormat": "Console",
    "ProductionSamplingRate": 0.0,
    "WhitelistedEndpoints": [
      "/health",
      "/metrics"
    ]
  }
}
// Program.cs integration
var diagnosticsConfig = builder.Configuration.GetSection("QueryDiagnostics");
builder.Services.AddQueryDiagnostics(options =>
{
    options.SlowQueryThresholdMs = diagnosticsConfig.GetValue<int>("SlowQueryThresholdMs");
    options.NPlusOneThreshold = diagnosticsConfig.GetValue<int>("NPlusOneThreshold");
});

builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
{
    opts.UseSqlServer(builder.Configuration.GetConnectionString("Default"));
    opts.AddInterceptors(sp.GetRequiredService<EfCoreQueryAuditor>());
});

var app = builder.Build();
app.UseRouting();
app.UseQueryDiagnostics(); // Place after routing for accurate endpoint names
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

Quick Start Guide

  1. Install Dependencies: Ensure your project targets .NET 8 or later. EF Core 8+ is required for ICommandInterceptor support.
  2. Register Services: Call AddQueryDiagnostics() in Program.cs, then attach the interceptor to your DbContext configuration using AddInterceptors().
  3. Add Middleware: Insert app.UseQueryDiagnostics() after UseRouting() to ensure endpoint metadata is available for accurate logging.
  4. Configure Thresholds: Adjust SlowQueryThresholdMs and NPlusOneThreshold in appsettings.json to match your database latency profile.
  5. Validate: Run the application, trigger an endpoint with navigation properties, and observe console warnings for repeated queries or duration breaches. Add .Include() or .Select() projections to resolve flagged patterns.