Skip to main content
Modern .NET API Patterns

Modern .NET API Design: Solving Common Architectural Mistakes for Robust Backend Systems

Every .NET API starts with good intentions. You follow clean architecture, add a repository layer, inject MediatR for CQRS, and write unit tests. Six months later, the team is afraid to touch the CreateOrderHandler because it has eleven dependencies and a bug in the validation pipeline. The API returns 500 errors when a third-party service is slow, and the logs don't tell you which request failed. This article is for the developer who has seen that codebase and wants to avoid building it again. We focus on six architectural mistakes that consistently cause pain in production .NET APIs. For each mistake, we explain why it happens, show a concrete example of the problem, and offer a better pattern that works across team sizes and deployment environments. The goal is not to prescribe a single architecture but to give you decision criteria you can apply to your own context. 1.

Every .NET API starts with good intentions. You follow clean architecture, add a repository layer, inject MediatR for CQRS, and write unit tests. Six months later, the team is afraid to touch the CreateOrderHandler because it has eleven dependencies and a bug in the validation pipeline. The API returns 500 errors when a third-party service is slow, and the logs don't tell you which request failed. This article is for the developer who has seen that codebase and wants to avoid building it again.

We focus on six architectural mistakes that consistently cause pain in production .NET APIs. For each mistake, we explain why it happens, show a concrete example of the problem, and offer a better pattern that works across team sizes and deployment environments. The goal is not to prescribe a single architecture but to give you decision criteria you can apply to your own context.

1. Who Needs This and What Goes Wrong Without It

This guide is for backend developers and technical leads who are designing or refactoring a .NET API—whether it is a new greenfield service or an existing monolith being broken apart. If you have ever spent a weekend debugging a mysterious timeout, or argued in a code review about whether the repository pattern is worth it, you are in the right place.

Without addressing these architectural mistakes, teams commonly face a set of predictable problems. First, the API becomes brittle: a change in one endpoint breaks another because of shared mutable state or over-coupled middleware. Second, observability suffers: when a request fails, you cannot trace it through logs, metrics, and traces because the diagnostic information is scattered across handlers and filters. Third, performance degrades unpredictably: blocking calls inside async methods, missing cancellation tokens, and chatty database queries turn a responsive API into a slow one under load. Fourth, testing becomes a chore: mocking a deep stack of abstractions takes more code than the logic being tested, so tests are skipped or become brittle themselves.

These problems compound over time. The team slows down, bugs slip into production, and the API becomes a source of frustration rather than a reliable service. The patterns we discuss here are not theoretical—they are extracted from real projects where these exact issues caused outages, missed deadlines, and developer burnout.

What You Will Gain

By the end of this article, you will have a clear checklist of what to avoid and what to adopt. You will understand the trade-offs between Minimal APIs and Controllers, know when to use MediatR and when to skip it, see why direct dependency injection often beats repository abstractions, and learn how to structure middleware for observability and error handling. Each section includes code snippets and decision rules you can apply immediately.

2. Prerequisites and Context Readers Should Settle First

Before we dive into the patterns, it helps to agree on the baseline. We assume you are working with .NET 6 or later (ideally .NET 8 or .NET 9), using ASP.NET Core, and have basic familiarity with dependency injection, middleware, and async/await. If you are on .NET Framework or an older version of ASP.NET, some patterns still apply, but the tooling and APIs differ—consider upgrading first to get the full benefit.

You should also have a clear picture of your API's requirements. Are you building a public-facing REST API with high traffic, an internal microservice with a few consumers, or a BFF (backend for frontend) that aggregates data for a single client? The answer changes which patterns fit best. For example, a high-throughput API benefits from Minimal APIs and raw ADO.NET for performance, while an internal service with complex business logic may prefer Controllers and a rich domain model. We will call out these trade-offs as we go.

Another important prerequisite is your team's familiarity with the patterns. Introducing MediatR or a full CQRS setup to a team that has never used it can backfire if there is no time for learning and code review. Similarly, removing a repository layer that everyone is comfortable with might cause more confusion than benefit. Our advice is to start with the simplest pattern that meets your needs and add complexity only when you have evidence that the simpler approach is causing pain.

When to Skip This Article

If you are building a prototype or a throwaway API, most of these patterns are overkill. Use Minimal APIs, skip the abstraction layers, and focus on getting the job done. Come back when the API needs to survive beyond the first release. Also, if you are using a framework like FastEndpoints or Carter, some of the patterns are built-in—adapt the advice to your framework's conventions rather than fighting them.

3. Core Workflow: Solving Six Common Architectural Mistakes

We present the solution as a sequence of six steps, each addressing one mistake. You can apply them in order, but they are independent enough that you can jump to the one causing you the most pain.

Mistake 1: Overusing MediatR for Every Request

MediatR is a popular library for implementing the mediator pattern in .NET. It decouples request senders from handlers, which sounds great for clean architecture. The mistake is using it for every endpoint, even simple CRUD operations that just call a single service method. The result is an explosion of small classes—one command, one handler, one response—that adds ceremony without value. You end up with dozens of files that each do one line of work, making navigation harder and testing more complex because you now have to mock the mediator itself.

Better approach: Use MediatR only when you have cross-cutting concerns like logging, validation, or transaction handling that apply to multiple handlers and would be duplicated otherwise. For simple endpoints, call the service layer directly from the controller or Minimal API handler. If you later need MediatR for a specific endpoint, you can introduce it incrementally. A good rule of thumb: if your handler does nothing but delegate to a repository or service, skip MediatR.

Mistake 2: Repository Abstraction Without Benefit

The repository pattern is often added to abstract the data access layer, making it easier to swap databases or unit test. In practice, most teams never swap databases, and the repository adds a layer that leaks EF Core details anyway (like IQueryable). The real problem is that repositories often become a god class with methods for every query, or they are so generic that they provide no abstraction at all.

Better approach: Use EF Core's DbContext directly in your service or handler. It is already a unit of work and repository pattern combined. If you need to mock data access for tests, use an in-memory database or a test-specific DbContext. For complex queries, create dedicated query objects or use raw SQL with Dapper. Reserve repositories for cases where you have a genuine need to abstract the storage (e.g., supporting both SQL Server and Cosmos DB) and keep them focused on aggregate roots, not every entity.

Mistake 3: Ignoring Async and Cancellation Tokens

Blocking calls inside async methods (like .Result or .Wait()) are a common source of thread pool starvation and deadlocks. Even worse, many developers forget to pass cancellation tokens through the call stack, so when a client disconnects, the server continues processing the request unnecessarily, wasting resources and potentially causing timeouts.

Better approach: Always use async/await all the way down. Never block on async code. Pass CancellationToken from the controller action to every database call, HTTP call, and long-running operation. Use HttpContext.RequestAborted as the default token. For background tasks, use IHostedService or BackgroundService with proper cancellation handling. Test your API under load with simulated disconnects to verify that tokens are respected.

Mistake 4: Middleware Sprawl and Poor Error Handling

Middleware is powerful, but it is easy to add too many custom middleware components that duplicate functionality or interfere with each other. Common issues include custom exception handling middleware that catches exceptions but returns a generic 500, logging middleware that logs every request twice, and authentication middleware that is applied twice because of a misconfigured order.

Better approach: Use the built-in middleware for standard concerns (CORS, authentication, static files). For custom error handling, use the ProblemDetails middleware or a single exception handling middleware at the top of the pipeline that returns structured error responses. For logging, rely on the built-in request logging and add structured logging with Serilog or OpenTelemetry instead of custom middleware. Keep middleware count low and test the pipeline order explicitly in integration tests.

Mistake 5: Configuration Overload and Tight Coupling

Many APIs load configuration from multiple sources (appsettings.json, environment variables, Azure App Configuration, Key Vault) without a clear hierarchy, leading to confusion about which value wins. Worse, configuration is often injected directly into services as IOptions with the entire section, coupling the service to the configuration structure and making it hard to test with different values.

Better approach: Define a clear configuration hierarchy and use the Options pattern with named options or post-configuration to bind to strongly typed settings objects. Inject only the specific settings your service needs, not the whole IOptions wrapper. Use IConfiguration directly only in the composition root (Program.cs). For secrets, use Azure Key Vault or environment variables, never appsettings.json. Validate configuration at startup using ValidateOnStart to catch missing values early.

Mistake 6: Missing Observability (Logs, Metrics, Traces)

An API without observability is a black box. When something fails, you have to reproduce the issue locally because you cannot see what happened in production. Common mistakes include logging only errors, not logging enough context (like correlation IDs), and not exporting metrics or traces to a monitoring system.

Better approach: Use OpenTelemetry for distributed tracing and metrics. Add a correlation ID to every request and include it in all logs. Use structured logging with Serilog or Microsoft.Extensions.Logging, and log at appropriate levels (Debug for detailed info, Information for normal operations, Warning for expected issues, Error for failures). Export traces and metrics to a backend like Jaeger, Prometheus, or Application Insights. Set up health checks and alerting on key metrics like request duration and error rate.

4. Tools, Setup, and Environment Realities

Implementing these patterns requires some tooling. For dependency injection, ASP.NET Core's built-in container is sufficient for most projects. If you need advanced features like decorators or interceptors, consider Autofac or Scrutor. For validation, FluentValidation works well with MediatR pipelines or Minimal API filters. For serialization, System.Text.Json is the default and works fine for most APIs; switch to Newtonsoft.Json only if you need specific features like ReferenceLoopHandling.

For testing, use xUnit or NUnit with Moq or NSubstitute for mocking. Prefer integration tests over unit tests for API behavior: use a test host with WebApplicationFactory to spin up the full pipeline and test against an in-memory database or test container. This catches configuration and middleware issues that unit tests miss.

Environment realities matter. In a containerized environment (Docker, Kubernetes), use environment variables for configuration and structured logging to stdout. In Azure App Services, use Application Insights for monitoring. In on-premises, you might use Serilog sinks to files or Elasticsearch. The patterns adapt, but the principles remain: decouple configuration from code, log with context, and trace requests across services.

Choosing Between Minimal APIs and Controllers

Minimal APIs are great for simple endpoints and microservices where you want minimal ceremony. Controllers are better for larger APIs with complex routing, model binding, and action filters. Do not mix both in the same project unless you have a clear reason (e.g., migrating gradually). A pragmatic approach: start with Minimal APIs for new services and switch to Controllers if you find yourself repeating filter logic or needing action-level attributes that Minimal APIs do not support well.

5. Variations for Different Constraints

Not every API has the same constraints. Here are variations of the patterns for common scenarios.

High-Throughput APIs

If your API handles thousands of requests per second, avoid MediatR and repository abstractions entirely. Use Minimal APIs with raw ADO.NET or Dapper for data access. Keep middleware minimal—only what is necessary for security and routing. Use pooled DbContexts or separate read/write connections. For caching, use IDistributedCache with Redis. Profile with BenchmarkDotNet to find bottlenecks.

Microservices with Event-Driven Communication

For microservices that communicate via messages (RabbitMQ, Kafka), use MediatR for in-process domain events, but keep request handling simple. Use the Outbox pattern for reliable message publishing. For observability, propagate trace context across message boundaries using OpenTelemetry's PropagationContext. Avoid sharing databases between services; each service owns its data.

Legacy Migration from .NET Framework

When migrating from .NET Framework to .NET, resist the urge to keep old patterns like static managers or singletons. Embrace dependency injection from the start. Use the Microsoft.AspNetCore.SystemWebAdapters only as a temporary bridge. Rewrite controllers one at a time, starting with the least coupled endpoints. Use feature flags to toggle between old and new implementations during migration.

APIs with Complex Business Logic

If your API has complex validation rules, workflows, or state machines, consider using a domain model with rich objects rather than anemic DTOs. MediatR can help organize the logic into handlers, but keep handlers focused on orchestration, not business rules. Use specification pattern for queries and strategy pattern for algorithms. Write unit tests for domain logic without mocking infrastructure.

6. Pitfalls, Debugging, and What to Check When It Fails

Even with good patterns, things go wrong. Here are common pitfalls and how to debug them.

Async Deadlocks

If your API hangs under load, check for blocking calls on async code. Use ConfigureAwait(false) in library code, but in ASP.NET Core it is usually not needed because there is no synchronization context. The real fix is to ensure every async method is awaited. Use a tool like Microsoft.VisualStudio.Threading.Analyzers to catch blocking calls at compile time.

Memory Leaks from Captured Dependencies

If your API's memory usage grows over time, check for singletons that capture scoped or transient dependencies. For example, a static cache that holds references to DbContext instances will leak memory. Use IHostedService for background work and avoid static state. Use dependency injection's scoped lifetime for per-request services.

Configuration Not Updating

If you change a configuration value but the API does not pick it up, check that you are using IOptionsSnapshot or IOptionsMonitor instead of IOptions. The latter is read once at startup. Also verify that the configuration source (e.g., Azure App Configuration) is correctly reloading.

OpenTelemetry Data Not Appearing

If traces or metrics are missing, check that you have added the correct instrumentation packages (e.g., OpenTelemetry.Instrumentation.AspNetCore) and configured the exporter. Ensure the AddOpenTelemetry call is before any middleware that might short-circuit the request. Use the console exporter for local debugging.

Integration Tests Failing in CI

If tests pass locally but fail in CI, check for environment-specific configuration (e.g., connection strings, API keys). Use appsettings.Test.json or environment variables in CI. Avoid using the production database in tests; use an in-memory database or a test container. Ensure tests clean up after themselves.

Next Steps

Start by auditing your current API for these six mistakes. Pick the one that causes the most pain and refactor it first. Write integration tests for the changed endpoints. Add OpenTelemetry if you do not have it. Then move to the next mistake. Over time, your API will become more robust, easier to maintain, and less stressful to operate.

Share this article:

Comments (0)

No comments yet. Be the first to comment!