Every .NET API starts with good intentions. But as features pile on and deadlines tighten, small shortcuts harden into anti-patterns that make the codebase brittle, slow, and hard to evolve. This guide walks through six common design flaws we see in production .NET APIs—and how to fix them with modern patterns.
1. The Monolithic Controller Trap
The most frequent anti-pattern we encounter is the single, bloated controller that handles everything from authentication to reporting. A team inherits a project where the OrdersController has 40+ actions, mixed concerns, and dependencies injected for every service in the system. The result: the controller is impossible to unit test, any change risks breaking unrelated endpoints, and the class file regularly exceeds 2000 lines.
Symptoms of a bloated controller
Look for these warning signs: the controller references more than five different services, contains inline validation logic, or has action methods that call database directly via DbContext. Another red flag is when the controller has both GET and POST methods that share no common logic—they likely belong in separate handlers.
Refactoring into vertical slices
Modern .NET offers two clean paths: minimal APIs for simple endpoints, and MediatR with CQRS for complex operations. Start by extracting each action into a dedicated handler class. For example, instead of a CreateOrder action in the controller, create a CreateOrderHandler that implements IRequestHandler<CreateOrderCommand, OrderResponse>. The controller then becomes a thin routing layer that calls IMediator.Send(). This immediately makes each operation testable in isolation and allows teams to work on different endpoints without merge conflicts.
When to avoid this refactor
If your API has fewer than ten endpoints and no plans to grow, the overhead of MediatR may not be justified. In that case, keep the controller but enforce a strict separation: move validation to FluentValidation validators, business logic to services, and data access to repositories. The key is to keep the controller under 200 lines and limit injected dependencies to three or fewer.
2. Chatty Endpoints and Overfetching
Another widespread anti-pattern is the chatty API—where a client must make multiple round trips to assemble a single view. For instance, a mobile app calls /api/users/{id} to get user info, then /api/orders?userId={id} to fetch orders, then /api/reviews?userId={id} for reviews. Each call adds latency and server load. The fix is not to throw everything into one endpoint, but to design for the client's actual data needs.
GraphQL vs. OData vs. custom DTOs
Three approaches can reduce chattiness. GraphQL lets clients request exactly the fields they need, but it adds complexity and requires a schema layer. OData provides query capabilities over REST, but it can expose internal data structures and complicate caching. For most teams, the pragmatic choice is custom DTOs tailored to specific client views. For example, a UserDashboardResponse that includes user profile, recent orders, and review summary in one payload. This keeps the API contract explicit and avoids the overhead of a query engine.
Using AutoMapper wisely
When building DTOs, teams often turn to AutoMapper to map between entities and DTOs. But misuse leads to performance issues and hidden bugs. We recommend using AutoMapper only for simple, flat mappings. For complex projections, write explicit mapping code or use Select() with LINQ to directly project to DTOs from the database. This avoids the N+1 query problem and makes the mapping logic visible.
Pagination and field selection
Even with custom DTOs, returning all records is a scalability killer. Always implement pagination with Skip and Take parameters, and consider adding a fields query parameter that lets clients specify which properties to include. This reduces payload size and server processing time, especially for list endpoints.
3. Ignoring Async I/O and Connection Pool Exhaustion
A common mistake in .NET APIs is blocking on async calls, often by using .Result or .Wait() on tasks. This ties up thread pool threads and can lead to deadlocks in ASP.NET context. More subtly, some developers use synchronous database calls (ToList() instead of ToListAsync()) or call external APIs synchronously, causing the thread to block while waiting for I/O.
The async all-the-way-down principle
The rule is simple: once you use async, let it propagate through the entire call stack. Use async Task for all action methods, repository calls, and service methods. Avoid mixing async and sync in the same call chain. If you must call a sync library from an async context, wrap it in Task.Run() only as a last resort—better to find an async alternative.
Connection pooling pitfalls
Another async-related issue is connection pool exhaustion. When you open a SqlConnection and don't dispose it properly (or hold it open during slow I/O), the pool runs out of connections, causing timeouts. Always use using statements or await using for disposable resources. For high-throughput services, consider using SqlConnection.OpenAsync() and setting Max Pool Size in the connection string to a reasonable value (e.g., 200).
Monitoring with structured logging
To catch async issues early, instrument your API with structured logging. Log the time spent in each async operation and look for patterns where synchronous waits appear. Use Serilog with async sinks to avoid impacting performance. A simple log entry like Log.Information("Fetched user {UserId} in {Elapsed} ms", id, sw.ElapsedMilliseconds) can reveal slow endpoints before they become problems.
4. Error Handling as an Afterthought
Many APIs return generic 500 errors or throw exceptions with no useful information. This frustrates clients and makes debugging a nightmare. The anti-pattern is to let exceptions bubble up to the global error handler without customization, or to wrap every method in try-catch blocks that swallow errors.
Problem Details for HTTP APIs
The standard fix is to implement RFC 7807 Problem Details. In .NET, you can use ProblemDetails class or the Results.Problem() method in minimal APIs. This returns a consistent JSON structure with type, title, status, detail, and instance. For validation errors, include an errors dictionary with field-level messages. This makes it easy for clients to parse errors programmatically.
Using middleware for global handling
Create custom middleware that catches unhandled exceptions and maps them to appropriate status codes. For example, map NotFoundException to 404, ValidationException to 400, and UnauthorizedAccessException to 401. Log the full exception details but return only safe information to the client. This centralizes error handling and ensures consistency across all endpoints.
Don't over-catch
A common mistake is to catch exceptions too early—inside a service method—and return null or a default value. This hides failures and makes debugging difficult. Instead, let exceptions propagate to the middleware layer, where they are logged and transformed. Only catch exceptions in business logic when you need to retry or provide a fallback, and always rethrow if you can't handle them meaningfully.
5. Over-Abstracting and Premature Optimization
Some teams add layers of abstraction—interfaces, repositories, unit of work, service locators—before they are needed. This makes the codebase hard to navigate and slows down development. The anti-pattern is to design for hypothetical future requirements instead of current ones.
The YAGNI principle in practice
Start with concrete implementations. For data access, use DbContext directly in your handlers or minimal API endpoints. If you later need to mock the database for testing, extract an interface only for the specific methods you use. Don't create a generic IRepository<T> with methods like GetAll(), Add(), Update()—these often lead to inefficient queries and are rarely used in full.
When abstraction helps
Abstraction is valuable when you have multiple implementations (e.g., a payment gateway that supports Stripe and PayPal) or when you want to decouple from a specific library (e.g., wrapping a third-party SDK). In those cases, define a narrow interface that captures only the operations you need. Avoid generic interfaces that mirror every method of the underlying library.
Measuring before optimizing
Premature optimization often leads to complex caching layers, custom serializers, or over-engineered async patterns. Before adding complexity, measure your API's performance with tools like BenchmarkDotNet or Application Insights. Identify the actual bottlenecks—often they are database queries, not C# code. Optimize the top three slowest operations first, and leave the rest simple.
6. Neglecting API Versioning and Backward Compatibility
As an API evolves, breaking changes are inevitable. But many teams ignore versioning until a mobile client breaks in production. The anti-pattern is to modify existing endpoints without a versioning strategy, forcing all clients to update simultaneously.
URL vs. header versioning
Two common approaches are URL versioning (/api/v1/orders) and header versioning (custom header like X-API-Version: 2). URL versioning is simpler for clients and easier to cache, but it can lead to code duplication. Header versioning keeps URLs clean but requires clients to set headers correctly. For most public APIs, URL versioning is the safer choice because it's explicit and works with any HTTP client.
Using the API Versioning library
Microsoft's Microsoft.AspNetCore.Mvc.Versioning library supports both approaches and integrates with Swagger. You can annotate controllers or endpoints with [ApiVersion("1.0")] and [ApiVersion("2.0")], and the library automatically routes requests based on the version. It also allows you to deprecate old versions and return sunset headers to guide clients to upgrade.
Backward compatibility checklist
When adding a new version, ensure that old clients still work: never remove a field from a response that existing clients depend on, add new fields as optional, and keep the same error format. Use contract tests to verify that responses from v1 remain unchanged. If you must change behavior, consider adding a new endpoint instead of modifying an existing one.
These six anti-patterns are not exhaustive, but they cover the most common pain points we see in .NET APIs. Start by auditing your own codebase for these signs: a bloated controller, chatty endpoints, sync-over-async calls, generic error handling, premature abstraction, or missing versioning. Pick one anti-pattern to address this week—refactor a single controller into handlers, or add Problem Details middleware. Each small fix compounds into a more scalable, maintainable API that your team and your clients will thank you for.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!