Introduction
After working through dozens of legacy codebases, we've noticed the same exception handling mistakes repeated across projects—each one quietly eroding the reliability and debuggability of production systems. The patterns we're removing in exception handling anti-patterns: a practical .NET guide aren't obscure edge cases; they're the everyday decisions that turn a straightforward bug into a three-hour investigation.
In this post, we'll walk through the most common anti-patterns we encounter, show you why they're problems, and give you the concrete fixes to apply to your own codebase today.
Catching and swallowing exceptions silently
The silent catch is perhaps the most insidious anti-pattern. You'll see it everywhere:
try
{
var result = _orderService.CalculateTotal(order);
await _database.SaveAsync(result);
}
catch (Exception)
{
// Something went wrong, but we'll just ignore it
}This code compiles. It runs. And it hides failures so effectively that your monitoring alerts never fire. Three weeks later, a customer's order total is wrong, and you have no idea when or why it happened.
The problem is twofold: first, you're catching everything—database timeouts, null reference exceptions, network errors, logic bugs—all of it swallowed. Second, there's no trace. No log. No metric. The system appears to work while silently failing.
Real-world impact
We worked on a payment processing system where a try-catch like this existed in the reconciliation service. When a third-party payment API returned an unexpected 500 error, the code caught it and moved on. For six hours, transactions weren't being reconciled. The accounting team noticed discrepancies only during their daily close. The root cause? A silent catch and no monitoring.
The fix
At minimum, log what happened. Better still, make the decision explicit about which exceptions you'll handle and what you'll do about them:
try
{
var result = _orderService.CalculateTotal(order);
await _database.SaveAsync(result);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Order calculation failed for order {OrderId}. Returning default total.", order.Id);
return order.Subtotal; // Explicit fallback
}
catch (TimeoutException ex)
{
_logger.LogError(ex, "Database timeout saving order total for order {OrderId}. Will retry.", order.Id);
throw; // Propagate so caller can retry
}Now you're specific about what you catch, you log with context, and you either handle gracefully or propagate. Future maintainers—and your 3 AM self debugging—will thank you.
Using throw ex; instead of throw;
This one hurts because it looks right at first glance:
try
{
await _paymentGateway.ProcessAsync(payment);
}
catch (PaymentFailedException ex)
{
_logger.LogError(ex, "Payment failed");
throw ex; // ❌ Wrong
}The problem: throw ex; creates a new stack trace starting from that line, obliterating the original call stack. You lose the information about where the exception truly originated. When you catch this exception three layers up and log the stack trace, it points to your rethrow, not to the actual bug.
Compare the stack traces:
// With throw ex;
at MyNamespace.PaymentService.ProcessPaymentAsync(Payment payment) in PaymentService.cs:line 42
// With throw; (preserves original)
at MyNamespace.PaymentGateway.SendRequestAsync(String endpoint) in PaymentGateway.cs:line 156
at MyNamespace.PaymentService.ProcessPaymentAsync(Payment payment) in PaymentService.cs:line 40The second one tells you the actual failure point. The first one lies.
When you need to rethrow
Use bare throw; when you're logging or cleaning up and then rethrowing:
try
{
await _database.SaveAsync(entity);
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "Database save failed for entity {EntityId}", entity.Id);
throw; // ✅ Preserves original stack trace
}If you need to wrap an exception in a custom one, use the throw with innerException parameter so the chain remains intact:
try
{
await _externalApi.CallAsync();
}
catch (HttpRequestException ex)
{
throw new ApiIntegrationException("External API call failed", ex); // ✅ Preserves chain
}Using exceptions for normal control flow
Exceptions are expensive. They unwind the stack, capture traces, and allocate memory. They should represent exceptional situations, not be your loop condition:
// ❌ Anti-pattern: exception-driven control flow
public decimal GetAccountBalance(string accountId)
{
try
{
return _cache.Get<decimal>($"balance_{accountId}");
}
catch (KeyNotFoundException)
{
return _database.GetBalance(accountId);
}
}This code treats a cache miss—which is normal—as an exception. Every single call that isn't cached will incur the cost of exception handling.
The business logic is also muddied. A reader doesn't immediately see "check cache, fall back to database." They see exception handling, which signals "something went wrong."
The fix
Use the APIs designed for normal scenarios:
public decimal GetAccountBalance(string accountId)
{
if (_cache.TryGetValue($"balance_{accountId}", out var cached))
{
return cached;
}
var balance = _database.GetBalance(accountId);
_cache.Set($"balance_{accountId}", balance);
return balance;
}Now the code is clearer and you're not paying exception overhead for predictable miss scenarios.
Where this pattern sneaks in
Watch for it in:
- Dictionary/collection lookups (use
TryGetValue,ContainsKey, or LINQFirstOrDefault) - Parsing and validation (use
int.TryParse, not try-catch aroundint.Parse) - Cache retrieval (check for null or use a fallback pattern)
- Optional resource checks (use
File.Existsbefore opening, not FileNotFoundException)
Logging the same exception at every layer
Layered architectures can turn one exception into three identical log entries, each one less useful than the last:
// Repository layer
catch (DbException ex)
{
_logger.LogError(ex, "Database error saving order");
throw;
}
// Service layer
catch (Exception ex)
{
_logger.LogError(ex, "Order service failed");
throw;
}
// Controller layer
catch (Exception ex)
{
_logger.LogError(ex, "Order endpoint failed");
return StatusCode(500);
}Your logging system now has three entries for one error, each one more generic than the last. The repository layer had the real context. By the controller layer, you've lost it.
The strategy: log once with full context
Log where you have the most context. Usually that's where the error actually occurred. Then propagate without logging:
// Repository layer - log here with full detail
catch (DbException ex)
{
_logger.LogError(ex,
"Database error saving order. OrderId: {OrderId}, Operation: {Operation}, Retry: {WillRetry}",
order.Id, "INSERT", shouldRetry);
throw;
}
// Service layer - no logging, just propagate or wrap
catch (DbException ex)
{
// Maybe add business logic context via wrapping
throw new OrderProcessingException($"Failed to persist order {order.Id}", ex);
}
// Controller layer - no logging, it's already logged
catch (OrderProcessingException ex)
{
return BadRequest(new { error = ex.Message });
}If you want to log the controller's decision (returning a 400 vs. 500), that's fine—but log the response, not the exception again. The exception itself is already in your logs.
Common mistakes
Catching Exception and DbException broadly. These catch too much. Catch what you can actually handle: specific exceptions like TimeoutException, InvalidOperationException, or your own domain exceptions. Let others propagate.
Not providing enough context in logs. When you do log an exception, include the IDs and state that led to it. "Order save failed" is less useful than "Order save failed for OrderId 12345 in state Pending."
Swallowing exceptions to return a default value. If the default is wrong, you've shipped a bug silently. Return null or a specific "error" state instead, and let the caller decide how to handle it.
Treating async failures the same as sync. With async code, make sure you're not swallowing TaskCanceledException or OperationCanceledException unless you truly intend to. These signal timeout or cancellation, which are important.
Key takeaways
- Never silently catch
Exceptionwith an empty block. Log, handle explicitly, or propagate. - Use bare
throw;to preserve stack traces, neverthrow ex;. - Exceptions are for exceptional cases. Use Try* methods and predicates for normal control flow.
- Log exceptions once, at the layer where you have the most context. Propagate without logging elsewhere.
- Be specific about which exceptions you catch. The narrower your catch, the clearer your intent and the safer your code.
- Include contextual data—IDs, state, operation type—in your exception logs so you can actually debug in production.






Comments
Loading comments…