Most articles about .NET API anti-patterns flatten every bad practice into a uniform “do not do this” list. That is not useful. Some anti-patterns will crash production at 2 AM - others will slow your sprint velocity by 5%. Treating them the same is why teams waste a quarter renaming variables while a new HttpClient() in a hot loop quietly exhausts sockets in production. In this article, I will walk through 10 anti-patterns I keep seeing in real .NET 10 codebases, rank each by blast radius, share the actual failure mode I have seen for each one, and show the fix. Let’s get into it.
Quick verdict. If you only fix three anti-patterns in your .NET 10 API, fix these: async void outside event handlers (kills the process), new HttpClient() per request (exhausts sockets under load), and singleton injecting scoped services (corrupts state across requests). Everything else is velocity drain or cosmetic. The full severity matrix is below.
This article is the negative-framed counterpart to my opinion-driven .NET 10 tips piece. Skim that one first if you want the “what to do” version of this same surface.
Companion: 20+ Tips from a Senior .NET Developer
The positive counterpart - the .NET 10 patterns I keep across every project, with a high-impact decision matrix.
What is an Anti-Pattern? (And How It Differs From a Code Smell or Bug)
An anti-pattern is a recurring solution to a common problem that looks reasonable on the surface but causes more harm than it solves. The key word is recurring: developers keep reaching for it because it seems right, then keep paying the bill. That is different from a bug (incorrect behavior in one place) and from a code smell (a symptom that something might be wrong but is not always wrong).
A bug crashes one feature. A code smell makes one file harder to read. An anti-pattern degrades the entire codebase at scale - the same wrong shape gets copied across services and the failure mode shows up everywhere.
In .NET 10 API development, anti-patterns cluster into three families: async correctness (where the language makes it easy to do the wrong thing), dependency management (where DI gives you the rope), and architectural laziness (where short-term convenience compounds into long-term maintenance cost). The 10 below cover all three families.
The Severity Matrix
Not every anti-pattern matters the same. Here is how I rank them by blast radius - the worst kind to commit is the one in the top row, and the cheapest to ignore is the bottom row:
| Severity | What It Means | This Article’s Anti-Patterns |
|---|---|---|
| 🔴 Kills production | The app crashes, deadlocks, or returns wrong data under real load | #1 async void, #2 sync-over-async, #3 new HttpClient(), #8 captive dependency |
| 🟠 Costs money | The app keeps running but burns CPU, memory, or cloud spend that you would not pay otherwise | #7 IEnumerable<T> from async, #9 runtime-reflection mapper in AOT, #10 panic-paying for commercial libraries |
| 🟡 Slows velocity | The code works fine but every new feature takes longer than it should | #5 fat controllers, #6 repository pattern over DbContext for trivial CRUD |
| ⚪ Cosmetic | The code works fine and is mildly harder to read | #4 throw ex (the failure mode is debugging pain, not production failure) |
The matrix is at the top because every section below references it. If your team has limited bandwidth, work top-down. A new HttpClient() in a request handler is a higher-priority fix than restructuring a fat controller.
Anti-Pattern #1: async void Outside Event Handlers
Severity: 🔴 Kills production.
The C# compiler lets you write async void methods and the language designers specifically warn against doing it. Yet I keep seeing it in API codebases, usually in fire-and-forget background work.
Bad:
app.MapPost("/orders", async (CreateOrderRequest req, IOrderService orders) =>{ SendConfirmationEmailAsync(req.CustomerEmail); // fire and forget var id = await orders.CreateAsync(req); return Results.Created($"/orders/{id}", new { id });});
static async void SendConfirmationEmailAsync(string email){ await Task.Delay(100); throw new InvalidOperationException("SMTP down");}The failure mode. An exception thrown inside an async void method cannot be caught by the calling code. It propagates to whichever SynchronizationContext captured the method - and in ASP.NET Core no sync context is captured by default, so the exception surfaces as an AppDomain.UnhandledException, which terminates the process. A request that should have returned 201 Created and quietly logged an email failure instead crashes the whole API instance.
I have debugged exactly this in production: an SMTP outage triggered a wave of async void background sends, the host died, the load balancer rotated through instances, and within minutes every instance was crashing the same way. The fix took 10 minutes; finding it took 4 hours because the stack traces all pointed at [GC] not at the email code.
Fix:
app.MapPost("/orders", async (CreateOrderRequest req, IOrderService orders, ILogger<Program> log) =>{ var id = await orders.CreateAsync(req); _ = Task.Run(async () => { try { await SendConfirmationEmailAsync(req.CustomerEmail); } catch (Exception ex) { log.LogError(ex, "Confirmation email failed for {Email}", req.CustomerEmail); } }); return Results.Created($"/orders/{id}", new { id });});
static async Task SendConfirmationEmailAsync(string email){ await Task.Delay(100); throw new InvalidOperationException("SMTP down");}The async Task return type forces the exception to surface on the task, and wrapping in Task.Run with a try/catch keeps fire-and-forget without crashing the host. The cleaner answer is to enqueue the email through a hosted service or background queue - which is a different article - but the minimum bar is never async void outside an event handler.
Microsoft’s asynchronous programming guidance treats async void as a non-default for exactly this reason - event handlers are the only place its semantics are intended.
Anti-Pattern #2: .Result and .Wait() on Async Code (Sync-Over-Async)
Severity: 🔴 Kills production.
When a synchronous method needs to call an async one, the temptation is to slap .Result or .Wait() on the call and move on. In a console app you might get away with it. In ASP.NET Core, you will not.
Bad:
app.MapGet("/products/{id}", (int id, IProductService products) =>{ var product = products.GetByIdAsync(id).Result; return Results.Ok(product);});The failure mode. Under any meaningful load (~50-100 concurrent requests), .Result causes thread pool starvation. The thread pool worker waits synchronously on a Task that will resume on a different thread pool worker - if all workers are blocked waiting, no thread is left to complete the work, and requests start timing out. The classic symptom: your API works fine in load tests up to N concurrent users, then falls off a cliff at N+1.
This is the most common “the app is slow” cause I see in production code reviews. The team adds more pods, scales the database, swaps cache providers - and the actual bottleneck is one .Result call inside a hot handler.
Fix: Use async/await end to end.
app.MapGet("/products/{id}", async (int id, IProductService products) =>{ var product = await products.GetByIdAsync(id); return Results.Ok(product);});If you are inside a constructor or other context that does not allow await, redesign the API. Async work belongs behind an InitializeAsync method or a factory, not inside Result. Minimal API endpoints, MVC actions, and middleware in ASP.NET Core all support async natively - there is no Result-needing surface in modern .NET 10.
Anti-Pattern #3: new HttpClient() Per Request
Severity: 🔴 Kills production.
HttpClient is IDisposable, so the natural reaction is using var http = new HttpClient() in every method that needs one. That instinct is wrong - and the wrong-ness has been a known issue since .NET Core 2.x.
Bad:
app.MapGet("/weather/{city}", async (string city) =>{ using var http = new HttpClient(); var response = await http.GetStringAsync($"https://api.example.com/weather/{city}"); return Results.Content(response, "application/json");});The failure mode. HttpClient holds an underlying HttpClientHandler that pools TCP sockets. When you Dispose the client, the sockets do not close immediately - they sit in TIME_WAIT state for 240 seconds (Windows default). Under sustained load, you run out of ephemeral ports, and every new request hits a SocketException: only one usage of each socket address is normally permitted.
I have seen this break a service that was averaging 50 RPS. The team’s instinct was to scale out, but the real fix was a one-line refactor to IHttpClientFactory. Microsoft’s IHttpClientFactory docs explicitly call this out as the canonical pattern in modern .NET.
Fix:
builder.Services.AddHttpClient("weather", c => c.BaseAddress = new Uri("https://api.example.com"));
app.MapGet("/weather/{city}", async (string city, IHttpClientFactory factory) =>{ var http = factory.CreateClient("weather"); var response = await http.GetStringAsync($"/weather/{city}"); return Results.Content(response, "application/json");});IHttpClientFactory pools the underlying handlers and rotates them periodically to avoid stale DNS - both problems solved with a one-line registration. For services that talk to many third parties, define a typed HttpClient per dependency.
Anti-Pattern #4: Catch-and-Rethrow With throw ex
Severity: ⚪ Cosmetic, but expensive in debugging time.
C# has two ways to rethrow a caught exception: throw and throw ex. They look identical. They are not.
Bad:
try{ await _repository.SaveAsync(entity);}catch (DbUpdateException ex){ _logger.LogError(ex, "Save failed"); throw ex;}The failure mode. throw ex resets the exception’s stack trace to the line of the rethrow, instead of preserving the original. Your error log says the exception came from line 47 of SomeService.cs - the line with throw ex. The line where the exception actually originated (deep inside EF Core, three frames down) is gone. You spend an hour reading EF Core source to figure out which SaveChangesAsync failed because the stack trace lies.
This is the cheapest production cost in the matrix - it does not break anything. But the time it costs in debugging compounds across the team. A senior engineer might lose 30 minutes per incident; a junior engineer might lose half a day.
Fix:
try{ await _repository.SaveAsync(entity);}catch (DbUpdateException ex){ _logger.LogError(ex, "Save failed"); throw;}Drop the ex. Plain throw preserves the original stack trace. Even better, do not catch what you cannot handle - in most cases the right answer is to let the exception bubble to a global exception handler.
Global Exception Handling in ASP.NET Core
The IExceptionHandler-based pattern that replaces try/catch noise across handlers.
Anti-Pattern #5: Fat Controllers (Or Fat Minimal API Handlers)
Severity: 🟡 Slows velocity.
A controller or endpoint that does validation, calls the database, transforms data, writes audit logs, sends emails, and returns the response is not technically broken. It is slow to extend - every new feature touches the same 200-line method.
Bad:
app.MapPost("/orders", async ( CreateOrderRequest req, AppDbContext db, IEmailService email, IAuditLog audit, ILogger<Program> log) =>{ if (string.IsNullOrEmpty(req.CustomerEmail)) return Results.BadRequest("Email required"); if (req.Items.Count == 0) return Results.BadRequest("At least one item required"); var customer = await db.Customers.FirstOrDefaultAsync(c => c.Email == req.CustomerEmail); if (customer is null) { customer = new Customer { Email = req.CustomerEmail }; db.Customers.Add(customer); } var order = new Order { Customer = customer, Items = req.Items, CreatedAt = DateTime.UtcNow }; db.Orders.Add(order); await db.SaveChangesAsync(); await email.SendOrderConfirmationAsync(customer.Email, order.Id); await audit.LogAsync($"Order {order.Id} created for {customer.Email}"); log.LogInformation("Order {OrderId} created", order.Id); return Results.Created($"/orders/{order.Id}", order.ToResponse());});The failure mode. Not production failure - organizational failure. Three engineers cannot work on this endpoint in parallel without merge conflicts. The validation logic cannot be reused for a different create flow. The email send blocks the response. The audit log and the order save are not in a transaction. Every new requirement adds another await to the same method.
Fix: Move the business logic out. Use a thin handler/command or a CQRS dispatcher. The endpoint becomes a translation layer between HTTP and your domain.
app.MapPost("/orders", async (CreateOrderRequest req, IDispatcher dispatcher) =>{ var result = await dispatcher.Send(new CreateOrderCommand(req)); return result.IsSuccess ? Results.Created($"/orders/{result.Value.Id}", result.Value) : Results.BadRequest(result.Error);});The validation moves to a validator. The order creation moves to a command handler. The email send moves behind an integration event or a background queue. The endpoint owns one concern: HTTP-to-domain translation.
CQRS and MediatR in ASP.NET Core
The pattern that gives you thin endpoints and reusable handlers.
Build Your Own CQRS Dispatcher (No MediatR)
The same thin-endpoint pattern without the commercial library tax.
Anti-Pattern #6: Repository Pattern Over DbContext for Trivial CRUD
Severity: 🟡 Slows velocity.
The repository pattern made sense in the EF 4 era, when ObjectContext was hard to mock and Unit of Work was not built in. In EF Core 10, DbContext is already a Unit of Work, and DbSet<T> is already a repository. Wrapping it in your own IProductRepository adds two interfaces and zero value.
Bad:
public interface IProductRepository{ Task<Product?> GetByIdAsync(int id, CancellationToken ct); Task<List<Product>> GetAllAsync(CancellationToken ct); Task AddAsync(Product product, CancellationToken ct); Task SaveChangesAsync(CancellationToken ct);}
public sealed class ProductRepository(AppDbContext db) : IProductRepository{ public Task<Product?> GetByIdAsync(int id, CancellationToken ct) => db.Products.FindAsync([id], ct).AsTask(); public Task<List<Product>> GetAllAsync(CancellationToken ct) => db.Products.ToListAsync(ct); public Task AddAsync(Product product, CancellationToken ct) => db.Products.AddAsync(product, ct).AsTask(); public Task SaveChangesAsync(CancellationToken ct) => db.SaveChangesAsync(ct);}The failure mode. Not failure - clutter. Every CRUD operation now requires updating two files: the interface and the implementation. The repository can never use EF Core features (Include, projection, AsSplitQuery) without leaking them through the interface. Testing is supposedly easier, but in practice you end up with brittle mocks that drift from the real EF behavior - the automapper-vs-mapster pattern of “the abstraction adds cost without benefit” applies here too.
My take: Skip the repository pattern entirely if your aggregate fits one table. Use DbContext directly in your command/query handlers. The repository becomes worth its weight only when you have a real aggregate that needs encapsulation rules (DDD-style) or when you want to swap data sources at runtime (rare).
Fix:
public sealed class CreateProductHandler(AppDbContext db){ public async Task<int> Handle(CreateProductCommand cmd, CancellationToken ct) { var product = new Product { Name = cmd.Name, Price = cmd.Price }; db.Products.Add(product); await db.SaveChangesAsync(ct); return product.Id; }}No interface, no IProductRepository, no mock. Integration tests with Testcontainers exercise the real EF Core path and the real database. Unit tests are not needed for code this thin.
Anti-Pattern #7: Returning IEnumerable<T> From Async Actions
Severity: 🟠 Costs money.
Returning IEnumerable<T> from an async endpoint looks ergonomic. It is also subtly wrong on the performance side.
Bad:
app.MapGet("/products", async (AppDbContext db) =>{ IEnumerable<Product> products = await db.Products.ToListAsync(); return products.Where(p => p.IsActive).Select(p => p.ToResponse());});The failure mode. The Where and Select execute synchronously during JSON serialization, on the request thread, one item at a time. For a list of 10,000 products, that means 10,000 deferred LINQ evaluations interleaved with the JSON writer. The endpoint that should have been O(n) allocations becomes O(n) allocations plus repeated delegate invocations on the hot path. Memory pressure and GC pauses go up. P99 latency under load goes from 80 ms to 250 ms.
Fix: Materialize before returning.
app.MapGet("/products", async (AppDbContext db) =>{ var products = await db.Products .Where(p => p.IsActive) .Select(p => p.ToResponse()) .ToListAsync(); return products;});Push the filter and projection into the IQueryable so EF Core translates them to SQL, then ToListAsync() to materialize. The JSON serializer now iterates a fully-materialized List<T> with no deferred work. Same logical result, dramatically different memory profile.
Tracking vs No-Tracking Queries in EF Core 10
The other side of this coin - read queries should be no-tracking by default.
Anti-Pattern #8: Singleton Injecting Scoped (Captive Dependency)
Severity: 🔴 Kills production.
ASP.NET Core’s DI container has three lifetimes: Transient, Scoped, and Singleton. Mixing them wrong creates a captive dependency - a longer-lived service holding a shorter-lived one.
Bad:
public sealed class CacheService(AppDbContext db) // AppDbContext is Scoped{ private readonly Dictionary<int, Product> _cache = new(); public async Task<Product?> GetAsync(int id, CancellationToken ct) { if (_cache.TryGetValue(id, out var product)) return product; product = await db.Products.FindAsync([id], ct); if (product is not null) _cache[id] = product; return product; }}
builder.Services.AddSingleton<CacheService>(); // BAD: captures Scoped DbContextThe failure mode. The first request resolves CacheService as a singleton, and the container injects the first request’s AppDbContext into it. Every subsequent request - across every connection, every user - uses that same DbContext. EF Core’s DbContext is not thread-safe. You get random InvalidOperationException: A second operation was started on this context errors under any concurrency. Worse: data from one user can leak into another user’s response because they share the same change tracker.
I have seen this exact bug in a production audit. The team’s “cache” service was holding the DbContext from whichever lucky request resolved it first; for the next 24 hours, all reads went through that one context, accumulating tracked entities until the process eventually OOM’d.
Fix: Inject IServiceScopeFactory and create a scope per cache miss. Or - better - use HybridCache, which is the .NET 10 first-party answer to this exact scenario.
public sealed class CacheService(HybridCache cache, IServiceScopeFactory scopeFactory){ public ValueTask<Product?> GetAsync(int id, CancellationToken ct) => cache.GetOrCreateAsync($"product:{id}", async (ct) => { using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); return await db.Products.FindAsync([id], ct); }, cancellationToken: ct);}
builder.Services.AddSingleton<CacheService>();In .NET 10, enable ValidateScopes = true in your host builder so the DI container catches captive dependencies at startup instead of in production:
builder.Host.UseDefaultServiceProvider(opts => opts.ValidateScopes = true);This single line surfaces every captive dependency the first time the app starts. It is on by default in Development but not in Production - which is the wrong default for catching real bugs. Turn it on.
When to Use Transient, Scoped, or Singleton in .NET
The decision tree for picking the right lifetime - the foundation under this anti-pattern.
HybridCache in ASP.NET Core 10
The .NET 10 caching primitive that makes the cache-around-DbContext anti-pattern unnecessary.
Anti-Pattern #9: Runtime-Reflection Mapper in a Native AOT Project
Severity: 🟠 Costs money (broken AOT or runtime failures).
This is a 2026 anti-pattern - it did not exist before .NET 8 made Native AOT (Ahead-of-Time compilation) a real production option. The mistake: adding AutoMapper or default-mode Mapster to a project with <PublishAot>true</PublishAot>.
Bad:
<PropertyGroup> <PublishAot>true</PublishAot></PropertyGroup><ItemGroup> <PackageReference Include="AutoMapper" Version="14.0.0" /></ItemGroup>builder.Services.AddAutoMapper(cfg => cfg.AddProfile<ProductProfile>());The failure mode. Two paths, both bad:
- The publish command emits a long list of AOT analyzer warnings about unsafe reflection, dynamic code, and trim warnings. The build still succeeds, but the team ships a binary that will throw
InvalidOperationException: dynamic code is not supportedthe first time a mapping runs in production. - The build trims away the reflection metadata, the mapper “works” for the first type it sees, then fails silently on the next type that was not exercised at build time.
I caught this exact case during a Native AOT migration: the team had moved to AOT for cold-start performance, the build was green, smoke tests passed locally - and the first production request that needed a less-common mapping crashed with a MissingMetadataException.
Fix: Use a source-generated mapper like Mapperly, or hand-write the mapping with extension methods. Both work cleanly under PublishAot = true.
<ItemGroup> <PackageReference Include="Riok.Mapperly" Version="4.2.1" /></ItemGroup>[Mapper]public partial class ProductMapper{ public partial ProductResponse ToResponse(Product product);}AutoMapper vs Mapster vs Manual Mapping in .NET 10 - Pick the Right One in 2026
The full mapping decision matrix - including the AOT-compatible options.
Anti-Pattern #10: Panic-Paying for a Commercial Library Before Checking the Free Tier
Severity: 🟠 Costs money (literal money).
When MediatR went commercial in 2025 and AutoMapper followed, the .NET community split into three reactions: pay immediately, panic-migrate, or write a calm one-page memo. Two of those are wrong.
Bad: Read the announcement on Monday, get the procurement form signed by Friday, never check whether your team’s gross annual revenue falls under the free Community tier.
The failure mode. Real money. AutoMapper commercial pricing starts around $489/year for the smallest tier; MediatR is in the same range. For organizations under $5M gross annual revenue, both products offer a free Community license that covers commercial use. Teams that did not check the tier paid for licenses they did not need.
The other failure mode is the inverse: panic-migrate to a less-mature alternative before checking whether the migration is needed at all. Migration cost for an existing AutoMapper Profile to Mapperly is small (~2-4 hours per 30 DTOs), but doing it on day-one of the announcement, without a memo, is how teams ship a half-finished migration that lingers for a quarter.
Fix: Run the four-question playbook the day you read the announcement, then wait a week:
- Do we fit the free tier under the new license?
- Is the last free version safe to stay on long-term (security advisories)?
- What does the alternative cost to adopt?
- Does the alternative give us something the old library cannot?
For most teams under $5M revenue, the answer to question 1 is yes - and the panic was unnecessary. For teams above the threshold, the answer to question 4 is sometimes yes (Mapperly’s Native AOT support, the custom CQRS dispatcher’s 4x performance) and the migration becomes a planned engineering decision, not a fire drill.
Severity Recap
The same matrix from the top, now with verdicts after walking through each anti-pattern:
| # | Anti-Pattern | Severity | Fix Cost | Fix Now? |
|---|---|---|---|---|
| 1 | async void outside event handlers | 🔴 Kills production | Low (search + replace) | Yes |
| 2 | .Result / .Wait() (sync-over-async) | 🔴 Kills production | Low-Medium (async propagation) | Yes |
| 3 | new HttpClient() per request | 🔴 Kills production | Low (AddHttpClient registration) | Yes |
| 4 | throw ex instead of throw | ⚪ Cosmetic (debugging cost) | Trivial | When you see it |
| 5 | Fat controllers | 🟡 Slows velocity | Medium (refactor to handlers) | Next sprint |
| 6 | Repository pattern over DbContext for CRUD | 🟡 Slows velocity | Medium (delete code, mostly) | Next sprint |
| 7 | IEnumerable<T> from async actions | 🟠 Costs money | Trivial (ToListAsync()) | This sprint |
| 8 | Singleton injecting Scoped | 🔴 Kills production | Low (ValidateScopes = true) | Yes |
| 9 | Runtime-reflection mapper in AOT | 🟠 Costs money | Medium (migration to Mapperly) | Before AOT publish |
| 10 | Panic-paying for commercial libraries | 🟠 Costs money (literal) | Free (run the 4 questions) | Before procurement |
The bolded Yes rows are the four anti-patterns I would fix in any codebase before touching anything else.
Key Takeaways
- An anti-pattern is a recurring “looks reasonable” solution that causes harm at scale. Not every anti-pattern is equal - some crash production at 2 AM, others just slow velocity.
- The four production-killers in 2026 are:
async voidoutside event handlers,.Result/.Wait()sync-over-async,new HttpClient()per request, and singleton services that inject scoped services (captive dependency). async voidcannot have its exceptions caught - the exception terminates the process. Useasync Taskand wrap fire-and-forget work inTask.Run+ try/catch, or use a hosted service.new HttpClient()per request exhausts sockets viaTIME_WAITsaturation. UseIHttpClientFactory.CreateClient()instead - it pools handlers and rotates DNS.ValidateScopes = truein production catches captive dependencies at startup instead of in production. Microsoft ships this on in Development but off in Production - flip it.- Repository pattern over
DbContextfor trivial CRUD is dead weight in EF Core 10.DbContextis already a Unit of Work andDbSet<T>is already a repository. - For 2026 .NET 10 services: a runtime-reflection mapper breaks Native AOT, and panic-paying for a commercial library before checking the free Community tier is wasted budget.
Frequently Asked Questions
What is an anti-pattern in software development?
An anti-pattern is a recurring solution to a common problem that looks reasonable on the surface but causes more harm than it solves at scale. It differs from a bug, which is incorrect behavior in one place, and from a code smell, which is a symptom that something might be wrong. Anti-patterns degrade the entire codebase because the same wrong shape gets copied across services and the failure mode shows up everywhere.
Why is async void bad in C#?
Async void methods cannot have their exceptions caught by the calling code. An unhandled exception inside an async void method propagates to the captured SynchronizationContext, and when no sync context is captured (the ASP.NET Core default), it surfaces as an AppDomain.UnhandledException and terminates the process. Use async Task as the return type instead, and wrap fire-and-forget work in Task.Run with a try/catch around it so exceptions are logged rather than crashing the host.
Why should I avoid creating new HttpClient instances per request?
Disposing an HttpClient does not immediately close its underlying TCP sockets. Sockets sit in TIME_WAIT state for around 240 seconds on Windows, so under sustained load you exhaust ephemeral ports and start hitting SocketException errors. Use IHttpClientFactory.CreateClient instead, which pools handlers and rotates them periodically to avoid stale DNS. This is the canonical pattern in Microsoft's .NET 10 documentation.
Is the repository pattern an anti-pattern in EF Core?
For trivial CRUD over a single aggregate, yes. EF Core's DbContext is already a Unit of Work and DbSet of T is already a repository, so wrapping them in your own IProductRepository adds two interfaces and no real value. The repository pattern is worth its weight only when you have a real domain aggregate with encapsulation rules, or when you genuinely need to swap data sources at runtime. For everything else, inject DbContext directly into your command and query handlers.
Why is throw ex bad and how should I rethrow exceptions?
Writing throw ex resets the exception's stack trace to the line of the rethrow, erasing the original origin. Plain throw preserves the original stack trace. Use throw without the exception variable inside the catch block when rethrowing. Even better, only catch exceptions you can actually handle - in most cases the right answer is to let the exception bubble up to a global exception handler that produces a ProblemDetails response.
What is the service locator anti-pattern in dependency injection?
The service locator anti-pattern is when you inject IServiceProvider into a class and resolve dependencies manually from it, instead of declaring them as constructor parameters. It hides the class's real dependencies, makes testing harder because mocks have to be wired through the provider, and bypasses the DI container's lifetime validation. The acceptable use is inside a factory class that is itself a thin wrapper for creating short-lived scopes - never as a substitute for constructor injection in business logic.
Is the singleton pattern an anti-pattern in .NET 10?
The singleton lifetime in ASP.NET Core's DI is fine - it is the right lifetime for services that hold no per-request state, such as configuration objects, factories, and caches. The anti-pattern is a singleton that captures a shorter-lived dependency, such as a scoped DbContext or HttpContext. This creates a captive dependency that breaks under concurrency. Enable ValidateScopes equals true in production to catch this at startup.
How is an anti-pattern different from a bug or a code smell?
A bug is incorrect behavior in one place - the fix is local. A code smell is a symptom in one file that might or might not indicate a problem - it needs human judgment to assess. An anti-pattern is a recurring solution shape that developers keep reaching for and that causes the same failure mode across the codebase. The fix scales with the codebase, not with the bug count.
Wrapping Up
Anti-patterns are not a list of forbidden code. They are a prioritization tool - knowing which mistakes will crash production at 2 AM versus which will just slow your team down lets you fix the right ones first. The four production-killers - async void, sync-over-async, per-request HttpClient, and captive dependencies - are the ones I would audit in any .NET 10 codebase before touching anything else. The velocity drains can wait for the next refactor. The cosmetic ones can wait until you happen to be in that file anyway.
The hardest part of fixing an anti-pattern is not the code change. It is convincing the team that the existing code is wrong when it has been “working fine” for two years. The severity matrix above is the artifact I use in code reviews: it answers “is this actually a problem?” with a tier, not a debate.
If you found this helpful, share it with your team - and if there is an anti-pattern you keep seeing in production that I missed, drop a comment and let me know. I will add it to the next revision.
Companion: 20+ Tips from a Senior .NET Developer
The positive counterpart - the .NET 10 patterns I keep across every project.
When to Use Transient, Scoped, or Singleton in .NET
The lifetime decision tree under Anti-Pattern #8.
Dependency Injection in ASP.NET Core Explained
The DI fundamentals under Anti-Patterns #6 and #8.
Global Exception Handling in ASP.NET Core
The IExceptionHandler pattern that replaces try/catch sprawl from Anti-Pattern #4.
CQRS and MediatR in ASP.NET Core
The thin-endpoint pattern under Anti-Pattern #5.
Build Your Own CQRS Dispatcher (No MediatR)
The 'replace' option in Anti-Pattern #10.
AutoMapper vs Mapster vs Manual Mapping in .NET 10 - Pick the Right One in 2026
The mapping decision matrix under Anti-Pattern #9.
Tracking vs No-Tracking Queries in EF Core 10
The EF Core read-path optimization that pairs with Anti-Pattern #7.
HybridCache in ASP.NET Core 10
The .NET 10 caching primitive that makes the cache-around-DbContext anti-pattern obsolete.
Keyed Services in .NET
The advanced DI feature that replaces some service-locator use cases.
Pagination, Sorting & Searching in ASP.NET Core
The right shape for the IEnumerable-from-async anti-pattern's parent problem.
Minimal APIs in ASP.NET Core
The endpoint style every example in this article uses.
Happy Coding :)




What's your take?
Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.