The 14-Point ASP.NET Core API Code Review Checklist for .NET Teams

Code reviews on ASP.NET Core APIs tend to follow a predictable pattern: reviewers catch the obvious issues — naming, formatting, missing tests — but let the subtle, production-breaking ones slide. A scoped service injected into a singleton, an async void hiding somewhere in an event handler, a DbContext shared across concurrent requests — these are the problems that surface at 2 AM, not during the pull request. An ASP.NET Core API code review checklist built for .NET teams forces the conversation onto those items before they reach production.
If you want to see these patterns implemented correctly in a complete, production-grade codebase, the full source code with edge-case handling and working tests is available on Patreon — everything wired together so you can see how each layer connects.
Understanding how validation, error handling, rate limiting, and authentication fit together in a single production API is what the ASP.NET Core Web API: Zero to Production course covers end-to-end — with a real codebase you can run and adapt from day one.
How to Use This Checklist
Run through each item for every pull request that touches API behaviour, service registration, data access, or cross-cutting concerns. Items marked 🔴 Critical have caused production incidents across teams; treat them as blockers. Items marked 🟡 Important are high-value catches that prevent subtle bugs or maintenance debt. Items marked 🟢 Good Practice improve long-term quality and should be flagged as suggestions rather than blockers.
1. Dependency Injection: Are Service Lifetimes Correct?
🔴 Critical
The single most common source of runtime exceptions in ASP.NET Core APIs is a lifetime mismatch — typically a scoped service (like DbContext) registered or injected into a singleton. At runtime, the singleton is created once and holds a reference to the scoped service for the entire application lifetime, which means request isolation breaks completely.
What to check:
No scoped service is injected into a singleton service or singleton middleware
HttpContextis not accessed directly inside a singleton — useIHttpContextAccessorif needed, and only with careDbContextis registered as scoped (the default), not singleton or transientBackground services (
BackgroundService,IHostedService) that need scoped dependencies useIServiceScopeFactoryto create a scope per operation — they never inject scoped services directly via constructor
What a failing pattern looks like: A CacheWarmingService : BackgroundService that has MyDbContext injected directly in the constructor. This compiles fine and will explode at startup with an InvalidOperationException.
2. Async/Await: No Fire-and-Forget Hiding in Plain Sight
🔴 Critical
async void methods are one of the most dangerous patterns in C# because unhandled exceptions inside them crash the process — they cannot be awaited, so the exception has nowhere to go. They belong only in event handlers where the signature is forced on you.
What to check:
No
async voidmethods outside of UI event handlers (there are essentially none in an API project)No
.Resultor.Wait()onTaskorValueTask— both block a thread and risk deadlock in some synchronisation contextsEvery
Task-returning method is awaited, not discarded with_ = SomeTask()CancellationTokenis accepted and passed downstream in long-running operations — especially to EF Core queries andHttpClientcallsNo
Task.Runwrapping synchronous code to fake async
3. Global Error Handling: Is the Exception Pipeline Correct?
🟡 Important
The canonical approach from .NET 8+ is IExceptionHandler — a typed, injected handler that maps known exceptions to structured Problem Details responses. Review whether the team is using this correctly, or still relying on a patchwork of try/catch blocks scattered through controllers.
What to check:
UseExceptionHandler()is registered early in the middleware pipeline (before routing, authentication, authorisation)Problem Details format (RFC 7807) is the response shape for all 4xx and 5xx errors — no custom ad-hoc error objects
500 responses never expose
exception.Message,StackTrace, or inner exception details to the callerIExceptionHandlermaps typed domain exceptions (e.g.,NotFoundException,ConflictException) to appropriate status codes — nocatch (Exception ex)blobs that swallow contextLog level is
Warningfor 4xx,Errorfor 5xx — both with structured context, never string interpolation
The decision between IExceptionHandler, middleware, and exception filters is covered in depth at ASP.NET Core Global Exception Handling: IExceptionHandler vs Middleware vs Filters.
4. Request Validation: FluentValidation Done Correctly
🟡 Important
Validation bugs are subtle — they don't always throw exceptions; they let bad data through silently. A validator that appears to work in unit tests can fail in integration if the registration is wrong or the auto-validation pipeline is not wired up.
What to check:
Validators are registered with
AddFluentValidationAutoValidation()so they fire automatically on model binding — no manualvalidator.Validate(model)scattered in controllersCross-field validation rules (e.g., "end date must be after start date") use
When()guards orCustom()to avoid redundant error messagesAsync validators (
MustAsync) are used for database-backed rules (e.g., uniqueness checks) — not synchronous validators that make blocking DB calls internallyThe validator class itself has unit test coverage using
TestValidate()— not just integration testsInput DTOs are separate from domain entities — validation lives on the DTO layer, not on the entity
5. EF Core: Query Patterns That Kill Performance at Scale
🟡 Important
EF Core makes it easy to write queries that look fine in development and become catastrophic under load. The patterns below are the ones that slip through reviews most often.
What to check:
All read-only queries use
AsNoTracking()— the change tracker adds overhead with no benefit for queries that do not updateNo N+1 queries: navigation properties loaded inside loops without
Include()or explicit projection are a classic failure modeFindAsync()is used for single-record lookups by primary key;FirstOrDefaultAsync()with a predicate for anything more complex — neverSingleOrDefaultAsync()on a full-scanFiltering happens at the database level (
Where()beforeToListAsync()) — not in-memory after materialisationCancellationTokenis passed to every EF Core query
For a focused treatment of EF Core query pitfalls, the EF Core Performance Tuning Checklist for High-Traffic APIs is worth adding to the review queue.
6. Authentication and Authorisation: No Accidentally Open Endpoints
🔴 Critical
Missing [Authorize] on a write endpoint is not a configuration oversight — it is a security incident waiting to happen. The pattern of applying a global authorisation policy and then explicitly opting out with [AllowAnonymous] is safer than the inverse, but it is not the default in most codebases.
What to check:
A global
DefaultPolicyis configured so all endpoints require authentication unless explicitly opted out[AllowAnonymous]is intentional and documented — not a forgotten debug shortcutRole-based and policy-based authorisation are used consistently; no ad-hoc
User.IsInRole()checks buried in service layersJWT configuration has
ClockSkew = TimeSpan.Zero— the default 5-minute skew allows tokens to remain valid past their stated expiryToken validation parameters include
ValidateIssuer,ValidateAudience, andValidateLifetime— alltrueSensitive endpoints (password change, admin actions, financial operations) have an additional authorisation policy, not just
[Authorize]
7. Rate Limiting: Protection That Actually Activates
🟡 Important
Rate limiting middleware exists in the pipeline but is only effective when policies are applied and the OnRejected handler returns the correct status code and headers. Review teams often add the middleware but forget the [EnableRateLimiting] attribute on the relevant endpoints.
What to check:
Rate limiting policies are applied to public-facing and unauthenticated endpoints at minimum
OnRejectedreturns HTTP 429 with aRetry-Afterheader and a Problem Details body — not a default 503Policies are partitioned by user identity or client IP — a global fixed-window policy that penalises all users equally is not appropriate for multi-tenant APIs
[DisableRateLimiting]is not applied to endpoints that handle write operations or authentication
The ASP.NET Core 10 Rate Limiting for SaaS: Enterprise Policy Guide covers the policy design decisions in detail.
8. Logging: Structured, Contextual, and at the Right Level
🟡 Important
Logging is the team's primary diagnostic tool in production. Unstructured logs, wrong log levels, and missing correlation context make incidents significantly harder to diagnose.
What to check:
All log calls use named placeholders (
_logger.LogInformation("Processing order {OrderId}", orderId)) — never string interpolation or$"..."— Serilog and other structured providers cannot extract properties from interpolated stringsLog levels are semantically correct:
Debugfor trace-level internals,Informationfor significant state changes,Warningfor recoverable problems and 4xx responses,Errorfor 5xx failures and unhandled exceptionsUseSerilogRequestLogging()(or equivalent) is in the pipeline so HTTP request logs carry correlation IDs automaticallySensitive data (passwords, tokens, PII, card numbers) is never logged — not even partially
No
Console.WriteLineorDebug.WriteLineleft over from development
9. Caching: No Stale Data, No Cache Stampede
🟡 Important
Caching bugs are particularly insidious because they are intermittent and environment-dependent. A cache that works perfectly with a single instance behaves unpredictably when scaled out unless the implementation is correct.
What to check:
IMemoryCacheis used only for data that is genuinely local to one instance (e.g., configuration) — shared data usesIDistributedCache(Redis) orHybridCache(.NET 9+)Cache keys include all dimensions that affect the result — missing a query parameter from the cache key causes data bleed between users
Expiry policies are set explicitly: both
AbsoluteExpirationandSlidingExpirationwhere appropriate — no entries that never expireCache invalidation is explicit on writes — no assumption that expiry alone is sufficient for write-heavy paths
HybridCacheis preferred for new code on .NET 9+ because it provides built-in stampede protection via theGetOrCreateAsyncpattern
10. Middleware Order: Pipeline Correctness
🔴 Critical
Middleware in ASP.NET Core executes in registration order, and that order has real semantics. Exception handling must come before routing; authentication must come before authorisation; rate limiting should sit before expensive processing. Getting this wrong produces subtle bugs that are hard to reproduce in isolation.
What to check:
UseExceptionHandler()orUseDeveloperExceptionPage()is first in the pipelineUseHttpsRedirection()is placed beforeUseStaticFiles()andUseRouting()UseAuthentication()always precedesUseAuthorization()— they cannot be swappedCustom middleware that reads request state (headers, body) is registered after
UseRouting()but before endpoint executionNo custom middleware duplicates what built-in middleware already provides —
UseResponseCompression()is not re-implemented manually
For a deeper look at middleware ordering mistakes and their consequences, see 7 Common ASP.NET Core Middleware Mistakes and How to Fix Them.
11. HTTP Client Usage: No Direct new HttpClient()
🟡 Important
Instantiating HttpClient directly is one of the most common anti-patterns in .NET codebases. It exhausts socket connections over time due to the way HttpClient handles DNS and connection pooling — the problem does not appear in development but causes intermittent 503 errors in production under sustained load.
What to check:
All
HttpClientusage goes throughIHttpClientFactory— either typed clients, named clients, or the basic factoryHttpClientinstances are never created withnew HttpClient()outside of test codeResilience policies (retry, circuit breaker, timeout) are applied using Polly via
AddStandardResilienceHandler()— not manual try/catch retry loopsBase addresses and timeouts are configured at registration time, not scattered across call sites
12. Cancellation Token Propagation
🟡 Important
A CancellationToken parameter that is accepted but never passed downstream is a common review miss. The token exists to propagate cancellation signals — if it is not threaded through, long-running operations continue even after the client has disconnected or the request has timed out.
What to check:
Controllers and action methods accept
CancellationTokenfrom the framework and pass it through to service layer callsService layer methods accept and propagate
CancellationTokento EF Core queries,HttpClientcalls, and channel reads/writesCancellationToken.Noneis not used as a substitute when a real token is available — this is a suppression, not a defaultBackground jobs that process items from a queue check
stoppingToken.IsCancellationRequestedin the processing loop
13. Configuration and Secrets: No Credentials in Code
🔴 Critical
Hardcoded connection strings, API keys, or passwords in appsettings.json or source code are a recurring security finding. The configuration hierarchy in .NET exists precisely to prevent this.
What to check:
No secrets, connection strings, or API keys appear in
appsettings.jsoncommitted to source controlEnvironment-specific configuration (production database strings, third-party API keys) comes from environment variables or a secrets manager (User Secrets for development, Azure Key Vault or similar for production)
The Options pattern (
IOptions<T>,IOptionsSnapshot<T>) is used to bind configuration sections to typed classes — noConfiguration["SomeKey"]scattered across servicesSensitive configuration keys are not logged at startup — a common pattern is to log all configuration for debugging and accidentally expose credentials
14. Response Shaping: Consistent, Versioned, and Leak-Free
🟡 Important
API responses are a contract. Exposing internal domain types directly, returning different shapes from different endpoints, or leaking stack traces in error responses all create compatibility issues and security risks.
What to check:
Response types are dedicated DTOs, not domain entities returned directly — returning an
Entitydirectly leaks internal structure and creates tight coupling between API contract and data modelAll endpoints declare response types explicitly using
ProducesResponseTypeattributes — or, for Minimal APIs, usingTypedResults— so OpenAPI documentation is accurateNo
dynamicreturn types orobjectreturn types on non-trivial endpointsAPI versioning is applied to breaking changes — not a new endpoint named
GetV2with no versioning strategyError responses across all endpoints use the same Problem Details shape — mixing
{ error: "..." }with{ message: "...", code: "..." }creates inconsistent client handling
☕ Prefer a one-time tip? Buy us a coffee — every bit helps keep the content coming!
Frequently Asked Questions
What is the most critical item on an ASP.NET Core API code review checklist?
Service lifetime mismatches — particularly injecting a scoped service such as DbContext into a singleton — are the most commonly missed and most reliably catastrophic. They compile cleanly, pass basic integration tests, and then produce InvalidOperationException errors in production, often under load. Every code review on a service registration file should treat this as the first thing to check.
How often should an ASP.NET Core API code review checklist be updated?
At minimum, review the checklist when a new major version of .NET or ASP.NET Core is released. New versions often introduce new patterns (e.g., IExceptionHandler in .NET 8, HybridCache in .NET 9) that supersede older approaches. Teams should also update the checklist after any production incident — if a bug slipped through review, the checklist should be extended to catch it next time.
Should the code review checklist be enforced by tooling or by reviewers?
Both. Static analysis tools (Roslyn analyzers, SonarQube, .editorconfig rules) can catch a subset of these issues automatically — particularly async void, missing ConfigureAwait, or suppressed CancellationToken. However, architectural and design-level items — such as whether a service lifetime is semantically correct, whether a caching key is complete, or whether an endpoint should require additional authorisation — require human judgment. The checklist bridges the gap between what tooling catches and what only an experienced reviewer notices.
Is it necessary to use AsNoTracking() on all read queries in ASP.NET Core?
Yes, for any query whose results are not going to be saved back to the database in the same request. The EF Core change tracker adds memory and CPU overhead for every entity it tracks, and that overhead scales with the number of entities returned. For APIs that serve read-heavy endpoints, omitting AsNoTracking() on queries that return large result sets is one of the most impactful performance regressions a team can introduce.
What is the correct order of authentication and authorisation middleware in ASP.NET Core?
UseAuthentication() must always precede UseAuthorization(). Authentication establishes the identity of the caller by validating credentials (JWT, cookie, API key). Authorisation then evaluates whether that established identity has permission to access the requested resource. Reversing the order means authorisation runs against an unauthenticated (anonymous) principal, which causes all protected endpoints to return 401, even for valid credentials.
How should a team handle CancellationToken in code reviews?
Review whether the token is both accepted and propagated. A method signature that accepts a CancellationToken but passes CancellationToken.None to its downstream calls provides no actual cancellation support — it is cosmetically compliant but behaviourally inert. Reviewers should trace the token from the controller action through to the EF Core query, the HttpClient call, or the channel read to confirm the full chain is cancellation-aware.
What is the right way to handle secrets in an ASP.NET Core API for production?
In production, secrets should never live in appsettings.json or environment variables that are committed to source control. The recommended hierarchy is: appsettings.json for non-sensitive defaults, User Secrets for development-time overrides, and a secrets manager (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) for production values. The Options pattern should be used to bind configuration to typed classes so access is centralised and typed, not scattered string-key lookups.






