A code review exercise covering the most common exception-handling mistakes in real .NET services.
The code shows the classic problems: throw ex destroys the stack trace; catching Exception and returning null swallows real failures; logging ex.Message loses the chain; catch-log-rethrow at every layer duplicates logs; .Result blocks and wraps errors in AggregateException; async void crashes the process; throwing Exception gives callers nothing to catch; clean-up outside finally leaks the lock; exceptions used for control flow (parse in try/catch); and cancellation logged as an error. The rewrite uses async Task all the way, using/finally for clean-up, Try methods for expected failures, a specific domain exception with an inner exception, filters for transient retries, and one global handler that logs once.
public class OrderService
{
private static readonly SemaphoreSlim _lock = new(1, 1);
public async void ProcessOrder(string json) // 1
{
try
{
_lock.Wait(); // 2
var order = JsonSerializer.Deserialize<Order>(json);
int qty;
try { qty = int.Parse(order.Quantity); } // 3
catch { qty = 1; }
var stock = _inventory.GetStockAsync(order.Sku).Result; // 4
if (stock < qty) throw new Exception("No stock"); // 5
await _repo.SaveAsync(order);
_lock.Release(); // 6
}
catch (Exception ex)
{
_logger.LogError("Order failed: " + ex.Message); // 7
throw ex; // 8
}
}
public Order GetOrder(int id)
{
try { return _repo.Get(id); }
catch (Exception) { return null; } // 9
}
}
I'd group findings by severity, because in a real review the order you raise things matters: first what can stall or crash production, then what corrupts data or hides failures, then diagnostics, then design.
(6) _lock.Release() outside finally. Any exception between Wait and Release (bad JSON, bad quantity, out of stock, database timeout) leaves the semaphore at 0 forever. Because the semaphore is static, every subsequent order in the process waits indefinitely. The service looks healthy (process up, CPU idle) while the queue backs up — the worst kind of outage. Fix: acquire, then try { ... } finally { Release(); }.
(1) async void. Callers can't await it, can't know when it finished, and can't catch its exceptions. An exception escaping an async void method is rethrown on the thread pool and terminates the process (in ASP.NET Core and workers). Combined with throw ex; at the end, every failed order crashes the host. Fix: async Task (and async Task<Result> here).
(2) _lock.Wait() and (4) .Result in async code. Both block a thread-pool thread. Under load this causes thread-pool starvation: requests queue, latency spikes, timeouts cascade. .Result also risks deadlocks under a SynchronizationContext, and wraps failures in AggregateException, so specific catch blocks never match. Fix: await _lock.WaitAsync(ct) and await _inventory.GetStockAsync(sku, ct).
(3) Exceptions as control flow + silent default. int.Parse in a try/catch is slow for bad input, but the real bug is business logic: invalid quantity becomes 1 and the order is placed anyway. The customer and warehouse get something nobody asked for. Fix: int.TryParse and reject invalid input with a validation result (400).
(9) Catch-all returning null. A database outage, a timeout or a mapping bug now looks like "order not found". Callers may create duplicates, show "no order" to customers, or skip refunds. Fix: let infrastructure exceptions propagate; return null only for a genuine not-found from the repository.
Missing null check after Deserialize. JsonSerializer.Deserialize<Order>("null") returns null, so order.Quantity throws NullReferenceException; malformed JSON throws JsonException. Both are client errors (400), not server errors (500).
(7) LogError("Order failed: " + ex.Message). Loses the stack trace and inner exceptions (the SqlException inside a DbUpdateException), breaks structured logging (no OrderId property, no template grouping), and may log sensitive data if messages contain payload values. Fix: logger.LogError(ex, "Order {OrderId} failed", order.Id) — and log once, at the boundary.
(8) throw ex;. Resets the stack trace to this line (CA2200), so the log and Application Insights point here rather than the real source. Fix: throw;, or better, remove this catch entirely and let the global handler log once.
Cancellation. No CancellationToken flows through, so when a client disconnects or the host shuts down, work continues and the lock is held longer. If cancellation were added, OperationCanceledException would currently be logged as an error.
(5) throw new Exception("No stock"). Callers can't distinguish "out of stock" (an expected business outcome → 409/422, maybe a back-order) from a crash (→ 500). Fix: a result type (Result.Fail(OrderErrors.InsufficientStock(sku, requested, available))), or a specific InsufficientStockException with properties if your architecture maps domain exceptions globally.
A global lock around I/O. A process-wide semaphore serialises all orders, including slow database calls, and only works on one instance — with two replicas in Azure, it provides no protection at all. Real stock correctness needs a database-level mechanism: a conditional update (UPDATE ... SET Stock = Stock - @qty WHERE Sku = @sku AND Stock >= @qty), optimistic concurrency (rowversion), or a per-SKU lock if serialisation is required.
One method doing everything (parsing, validation, locking, inventory, persistence) makes error handling tangled. Split validation from the application service.
public sealed class OrderService(IInventory inventory, IOrderRepository repo, ILogger<OrderService> logger)
{
public async Task<Result<OrderId>> ProcessOrderAsync(string json, CancellationToken ct)
{
OrderRequest? request;
try
{
request = JsonSerializer.Deserialize<OrderRequest>(json, JsonOptions.Strict);
}
catch (JsonException ex)
{
logger.LogWarning(ex, "Rejected malformed order payload"); // no payload contents logged
return OrderErrors.InvalidPayload;
}
if (request is null || !int.TryParse(request.Quantity, NumberStyles.None, CultureInfo.InvariantCulture, out int qty) || qty <= 0)
return OrderErrors.InvalidQuantity;
// Atomic, multi-instance-safe stock reservation in the database (no process-wide lock)
StockReservation reservation = await inventory.TryReserveAsync(request.Sku, qty, ct);
if (!reservation.Succeeded)
return OrderErrors.InsufficientStock(request.Sku, qty, reservation.Available);
OrderId id = await repo.SaveAsync(request.ToOrder(qty), ct); // transient retries handled by a resilience pipeline
return id;
// No catch-log-rethrow: unexpected exceptions reach the global IExceptionHandler, which logs once
// with the trace ID and returns a generic 500 ProblemDetails. Cancellation propagates.
}
public Task<Order?> FindOrderAsync(int id, CancellationToken ct) => repo.FindAsync(id, ct); // null = not found only
}
If keeping an in-process lock really were required, the minimum fix is:
await Gate.WaitAsync(ct);
try { /* work */ }
finally { Gate.Release(); }
I'd raise the lock leak and async void as blocking comments with the incident risk explained, suggest the rest as required changes, and propose guardrails: analysers CA2200 (throw ex), CA2201 (throwing Exception), CA1031 (catching general exceptions), VSTHRD100 (async void), VSTHRD002/VSTHRD103 (sync waits), CA2016 (forward CancellationToken), CA2254/CA1848 (log templates), set to errors; a short exception-handling checklist in the PR template; and a health check based on queue progress, not just process liveness.
[In a real incident at my company] a semaphore released outside finally caused the order processor to stop after [the first malformed message]; health checks stayed green because the process was alive, and the only log line had no stack trace because of ex.Message logging and throw ex. The review checklist above came out of that post-incident review.
A practical test of everything in exception handling, plus code review communication.
async Task, finally, Try methods, domain exceptions and a global handler, and suggests analyzers.throw ex, adds more try/catch, or keeps async void and .Result.| # | Problem | Failure mode in production | Detection | Fix |
|---|---|---|---|---|
| 1 | async void |
Process crash on any exception; callers can't await | Container restarts, UnhandledException logs |
async Task |
| 2 | Wait() in async |
Thread-pool starvation under load | threadpool-queue-length rising in dotnet-counters |
await WaitAsync(ct) |
| 3 | Parse in try/catch with default | Wrong orders placed; CPU cost for bad input | Business complaints; exception-count |
TryParse + reject |
| 4 | .Result |
Starvation, potential deadlock, AggregateException |
Hung requests; dumps showing blocked threads | await |
| 5 | throw new Exception |
Business outcomes become 500s and alerts | High 5xx rate with "No stock" | Result type / domain exception |
| 6 | Release outside finally |
Permanent lock: service stalls silently | Queue depth grows, CPU idle | try/finally |
| 7 | Log ex.Message |
No stack/inner exception; unstructured | Unhelpful logs | LogError(ex, template, args) |
| 8 | throw ex |
Stack trace points to wrong place | Misleading traces | throw; or no catch |
| 9 | Catch-all returns null |
Outages look like not-found; duplicate creation | Data anomalies | Let it propagate |
async void + throw ex crashes the processAn async void method uses AsyncVoidMethodBuilder. When an exception escapes, the builder calls SynchronizationContext.Post to rethrow it there if one exists (UI apps), or queues it to the thread pool otherwise. A thread-pool work item that throws an unhandled exception triggers AppDomain.UnhandledException and terminates the process. In this code, the catch block rethrows, so every failed order exits the process. In Kubernetes/Container Apps you'd see restart loops; in-flight work on other threads is lost.
Order A: Wait() -> count 0 -> int.Parse succeeds -> stock check throws Exception("No stock")
-> jumps to catch, Release() never called -> count stays 0
Order B: Wait() -> blocks forever (thread-pool thread pinned)
Order C: Wait() -> blocks forever
...
Because Wait() is synchronous, each new order consumes a thread-pool thread forever. The thread pool injects new threads slowly (about one or two per second once it believes it's starved), so request latency climbs across the whole service — not just orders. A memory dump (dotnet-dump analyze → clrthreads, dumpasync, syncblk) shows dozens of threads in SemaphoreSlim.Wait.
With WaitAsync the leak would still stall orders, but it wouldn't pin threads — the finally is what fixes correctness.
| Failure | Example | Mechanism | HTTP |
|---|---|---|---|
| Malformed input | Bad JSON, quantity "abc" | Validation/result | 400 (ValidationProblemDetails) |
| Business rule | Out of stock, order already shipped | Result type (or domain exception mapped by handler) | 409 / 422 |
| Not found | Unknown order ID | null/result |
404 |
| Transient infrastructure | Timeout, deadlock, 429 | Resilience pipeline retries, then propagate | 503 / 500 |
| Bug / unexpected | NRE, invalid state | Propagate to global handler | 500 |
| Cancellation | Client disconnect, shutdown | Propagate; not logged as error | 499 (logged) / none |
This table is the core of the review: each catch block in the original code mixes several rows together.
A static SemaphoreSlim only serialises within one process. With two App Service instances or pods, two orders for the last unit can both pass the check. Correct options:
UPDATE Inventory
SET Available = Available - @Qty
WHERE Sku = @Sku AND Available >= @Qty;
-- @@ROWCOUNT = 1 -> reserved; 0 -> insufficient stock
or EF Core optimistic concurrency ([Timestamp] byte[] RowVersion and handling DbUpdateConcurrencyException), or a reservation table with a unique constraint. This removes the lock entirely and turns "out of stock" into a clean result.
[LoggerMessage(Level = LogLevel.Error, Message = "Order {OrderId} failed during {Stage}")]
static partial void LogOrderFailed(ILogger logger, Exception ex, Guid orderId, string stage);
OrderId, Stage) are queryable; events are grouped.traceId correlation.[ ] No async void (except event handlers)
[ ] No .Result / .Wait() / .GetAwaiter().GetResult() in async code
[ ] Locks, connections, streams released in using / finally
[ ] No throw ex; wrapping keeps InnerException
[ ] No catch (Exception) that swallows or returns defaults
[ ] Expected failures use Try methods / validation / result types
[ ] Business rules use result types or specific domain exceptions
[ ] Exception object logged once, structured, no sensitive data
[ ] CancellationToken accepted and forwarded; cancellation not logged as error
[ ] Concurrency correctness doesn't rely on in-process locks across instances
| Rule | Detects |
|---|---|
| CA2200 | throw ex; |
| CA2201 | Throwing reserved/general types like Exception |
| CA1031 | Catching general exception types |
| VSTHRD100 | async void methods |
| VSTHRD002 / VSTHRD103 | Synchronous waits / sync calls in async methods |
| CA2016 | Not forwarding CancellationToken |
| CA2254 / CA1848 | Non-constant log templates / use LoggerMessage |
| CA1806 | Ignored method results |
<AnalysisLevel>latest-all</AnalysisLevel> (plus Microsoft.VisualStudio.Threading.Analyzers) and list the warnings.{ "Quantity": "abc" } and { "Quantity": "5" } with an out-of-stock SKU to the original and the rewrite; compare behaviour.dotnet-counters).public sealed class OrderService(
IInventory inventory, IOrderRepository repo, ILogger<OrderService> logger)
{
private static readonly SemaphoreSlim Gate = new(1, 1);
public async Task<Result<OrderId>> ProcessOrderAsync(string json, CancellationToken ct)
{
OrderDto? dto;
try
{
dto = JsonSerializer.Deserialize<OrderDto>(json);
}
catch (JsonException ex)
{
logger.LogWarning(ex, "Rejected malformed order payload");
return Result<OrderId>.Fail("order.invalid-json");
}
if (dto is null || !int.TryParse(dto.Quantity, out int qty) || qty <= 0)
return Result<OrderId>.Fail("order.invalid-quantity");
await Gate.WaitAsync(ct);
try
{
int stock = await inventory.GetStockAsync(dto.Sku, ct);
if (stock < qty)
throw new InsufficientStockException(dto.Sku, qty, stock); // mapped to 409 globally
for (int attempt = 1; ; attempt++)
{
try
{
return Result<OrderId>.Ok(await repo.SaveAsync(dto.ToOrder(qty), ct));
}
catch (TimeoutException) when (attempt < 3) // transient only, others propagate
{
await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt), ct);
}
}
}
finally
{
Gate.Release();
}
// No catch-log-rethrow: the global IExceptionHandler logs once with full context.
}
public Task<Order?> GetOrderAsync(int id, CancellationToken ct) =>
repo.FindAsync(id, ct); // null only means not found; outages still throw
}
throw exWhat happens: the review comments on the rethrow and misses the structural problems.
Why it is wrong: the worst production failures come from the lock released outside finally, async void and .Result, not from a lost stack trace.
Do instead: review flow (async, cleanup), meaning (types, results) and diagnostics (logging) in that order of severity.
What happens: every catch is replaced with throw;.
Why it is wrong: invalid input and business outcomes become 500s and pager alerts.
Do instead: classify failures: validation → 400, business rules → 409/422 via results, infrastructure → propagate.
What happens: the "fix" wraps every line in its own try/catch with logging. Why it is wrong: duplicates logging, hides the happy path, and still mixes concerns. Do instead: remove redundant catches; rely on one global handler plus targeted handling of expected cases.
return null with return default or an empty objectWhat happens: the catch-all in GetOrder returns new Order() instead.
Why it is wrong: still disguises outages as valid data — now harder to detect.
Do instead: let infrastructure exceptions propagate; reserve null for genuine not-found.
What happens: the review fixes Wait()/finally but keeps the semaphore for stock correctness.
Why it is wrong: it doesn't protect anything across multiple instances, and it serialises all orders.
Do instead: use an atomic database update, optimistic concurrency, or a reservation constraint.
async void to async Task but not awaiting callersWhat happens: callers still call ProcessOrderAsync(json); without await.
Why it is wrong: exceptions become unobserved task exceptions; work may outlive the request.
Do instead: await it, or enqueue it to a background processor with error handling.
CancellationTokenWhat happens: the rewrite is async but has no token.
Why it is wrong: client disconnects and shutdowns can't stop work; locks and connections are held longer.
Do instead: accept and forward CancellationToken everywhere (CA2016), and don't log cancellation as an error.
What happens: the new log statement includes the raw JSON payload "for debugging". Why it is wrong: payloads contain personal and possibly payment data; logs have broad access and long retention (Privacy Act APP 11, PCI DSS). Do instead: log identifiers and error codes, never payloads; use structured templates.
What happens: InsufficientStockException thrown for a common outcome during sales.
Why it is wrong: high exception rates cost CPU and create noisy telemetry exactly when traffic peaks.
Do instead: return a result type for expected business outcomes.
What happens: the file is fixed, but the same patterns reappear elsewhere. Why it is wrong: the team doesn't improve; the next incident has the same root cause. Do instead: enable the analysers as errors, add the checklist to the PR template, and add a queue-progress health check.
Seniors review error handling by asking three questions for each catch: what failure is this, who should know, and what state is the system left in.
| Question | Good answer | Smell |
|---|---|---|
| What failure? | specific type or filter | catch (Exception) |
| Who should know? | one boundary log, caller via type/result | log at every layer, or never |
| What state remains? | clean (using/finally, transaction) | leaked lock, half-written data |
| Is it expected? | Try/result | exception for control flow |
| Can callers act on it? | domain exception with data | Exception("...") |
They also turn findings into guardrails (analyzers, PR template, architecture tests) so the team improves, not just this file.
throw; rethrows the current exception and keeps its original stack trace. throw ex; throws the same object as if it were new, so the stack trace is reset to the line of throw ex, and you lose where it really failed. Analyzer CA2200 flags it. To add context, throw a new exception with the original as InnerException. To rethrow an exception you captured earlier, for example from another thread, use ExceptionDispatchInfo.Capture(ex).Throw(), which keeps the original trace and appends the new one. Throw expressions (x ?? throw new ...) and guard helpers such as ArgumentNullException.ThrowIfNull keep validation short.
In ASP.NET Core, register AddProblemDetails(), one or more IExceptionHandler implementations (.NET 8+) with AddExceptionHandler<T>(), and app.UseExceptionHandler() early in the pipeline. Handlers log once and map known exceptions to the right status code and an RFC 9457 ProblemDetails body, and never leak stack traces in production. For the process as a whole, AppDomain.CurrentDomain.UnhandledException is a last-chance notification: you can log and flush, but the process still terminates. TaskScheduler.UnobservedTaskException fires when a faulted task that nobody awaited is garbage-collected; it does not crash the process by default. In a BackgroundService, an unhandled exception in ExecuteAsync stops the host by default (.NET 6+). async void exceptions go to the synchronisation context and usually crash the process, so avoid async void except for event handlers.
A try block costs almost nothing when no exception is thrown; .NET uses table-based handling, so there is no work on the happy path (though a method containing try/catch may get fewer JIT optimisations such as inlining). Throwing is expensive: allocating the exception, capturing the stack trace, and the two-pass search and unwind cost microseconds, versus nanoseconds for a normal return, and more with deep stacks. .NET 9 replaced the C++-based exception handling with a managed implementation (from NativeAOT), making throw and catch roughly 2 to 4 times faster, but it is still far slower than returning a value. So keep exceptions for exceptional cases, and on hot paths with expected failures use Try methods (int.TryParse, TryGetValue), result types, and throw helpers that keep the throwing code out of line. Measure exception rates with dotnet-counters before optimising.
How to tell it (STAR)
**Situation:** At [company], our order processor stopped processing for [several hours] while reporting healthy, and the backlog reached [thousands] of orders. **Task:** I led the incident response and the follow-up review. **Action:** A memory dump showed every worker waiting on a semaphore. The code released it at the end of the `try` block, so the first malformed message left it locked forever. The only log line had no stack trace because the code logged `ex.Message` and used `throw ex`. I fixed the release with `finally`, removed `async void` and `.Result`, handled bad quantities as validation results, and let a global handler log once. I then ran a review of the rest of the service using a checklist and enabled analyzers to enforce it. **Result:** Processing resumed and the backlog cleared in [X minutes]. The analyzers found [N] similar issues elsewhere, which we fixed before they caused incidents, and we changed the health check to measure queue progress.
Last updated 30 Oct 2026.