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 RecordEx

🎉 Mid-Year Sale — Unlock Full Article

Base plan from just $4.99/mo or $49/yr

Sign in to read the full article and unlock all 635+ tutorials.

Sign In / Register — Start Free Trial

7-day free trial · Cancel anytime · 30-day money-back