📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Review this exception-handling code from an order service. Identify every problem, explain why each one matters in production, and show how you would rewrite it.

A code review exercise covering the most common exception-handling mistakes in real .NET services.

Senior CodeReview Advanced async code-review error-handling exceptions logging

🎯 Key points

  • `async void` and `.Result` cause crashes, lost errors, deadlocks and `AggregateException`
  • Clean-up such as `Release()` must be in `finally` or `using`, or failures leak locks
  • `throw ex;` loses the stack trace; prefer `throw;` or no catch at all
  • Logging `ex.Message` loses the stack and inner exceptions; log the exception object with a template
  • Catch-all blocks returning `null` or defaults hide outages as normal results
  • Expected failures (bad input) use Try methods or results, not exceptions
  • Business rule failures use specific domain exceptions or result types, never `Exception`
  • Turn findings into analyzers (CA2200, CA2201, CA1031, VSTHRD100, CA2016) and a review checklist

⏱ 30-second answer

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.

🗣 Full answer (senior)

Code under review

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
    }
}

How I would run this review

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.

Severity 1 — can stall or crash the service

(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).

Severity 2 — wrong data or hidden failures

(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).

Severity 3 — diagnostics

(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.

Severity 4 — design

(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.

The rewrite

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(); }

How I'd close the review

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.

Production example

[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.

🔍 What the interviewer is checking

A practical test of everything in exception handling, plus code review communication.

  • Green flags: finds most of the issues, explains production impact for each, prioritises (deadlock and lock leak first), rewrites with async Task, finally, Try methods, domain exceptions and a global handler, and suggests analyzers.
  • Red flags: only spots throw ex, adds more try/catch, or keeps async void and .Result.

📚 Learn in depth

1. Findings mapped to failure modes

# 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

2. Why async void + throw ex crashes the process

An 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.

3. The lock leak, step by step

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.

4. Classifying failures

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.

5. Stock correctness across instances

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.

6. Logging done right

[LoggerMessage(Level = LogLevel.Error, Message = "Order {OrderId} failed during {Stage}")]
static partial void LogOrderFailed(ILogger logger, Exception ex, Guid orderId, string stage);
  • Pass the exception object → stack trace, inner exceptions, exception type as a dimension.
  • Use templates → properties (OrderId, Stage) are queryable; events are grouped.
  • Don't log payloads or personal data (names, addresses, card data) — Privacy Act APP 11 and PCI DSS; log identifiers instead.
  • Log once, at the boundary (global handler, message consumer), with traceId correlation.

7. Review checklist

[ ] 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

8. Analysers

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

Try it yourself

  1. Paste the original code into a project with <AnalysisLevel>latest-all</AnalysisLevel> (plus Microsoft.VisualStudio.Threading.Analyzers) and list the warnings.
  2. Send { "Quantity": "abc" } and { "Quantity": "5" } with an out-of-stock SKU to the original and the rewrite; compare behaviour.
  3. Make the repository throw once in the original version and observe that later calls hang (and how many threads are blocked in dotnet-counters).
  4. Run two instances of the original against one database and race two orders for the last unit.

💻 Code

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
}

⚠ Common mistakes

1. Spotting only throw ex

What 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.

2. Fixing swallowed exceptions by rethrowing everything

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.

3. Adding more try/catch blocks

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.

4. Replacing return null with return default or an empty object

What 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.

5. Keeping the static lock as the stock guarantee

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.

6. Converting async void to async Task but not awaiting callers

What 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.

7. Forgetting CancellationToken

What 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.

8. Logging sensitive data while fixing logging

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.

9. Using exceptions for out-of-stock in a high-traffic path

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.

10. Not turning findings into guardrails

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate spots throw ex Why: Checks depth on stack trace preservation
    How to answer

    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.

  2. When: Candidate removes catch-log-rethrow Why: Checks where logging should happen instead
    How to answer

    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.

  3. When: Candidate replaces int.Parse with TryParse Why: Checks exception cost understanding
    How to answer

    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.

🛠 Real-world problems: how you handle them

Order processor silently stalled after one bad message
Situation
An order processing service consumed messages and processed them one at a time behind a `SemaphoreSlim`. One morning the queue backlog grew to [thousands] of messages while the service reported healthy.
Signals
Queue depth rising, no CPU use, health checks green, a single error log line 'Order failed: Input string was not in a correct format' from hours earlier.
Investigation
A memory dump showed every worker awaiting the semaphore. The code released it at the end of the `try`, so the first exception (a bad quantity) skipped `Release` forever. The error log had only the message, no stack, because of `ex.Message` logging and `throw ex`.
Resolution
Moved `Release` into `finally`, changed `async void` to `async Task`, replaced `.Result` with `await`, used `TryParse` with a validation result, and removed catch-log-rethrow in favour of the global handler.
Prevention
Enabled CA2200, CA1031, VSTHRD100 and VSTHRD002 as errors, added a health check based on queue progress rather than process liveness, and added the exception-handling checklist to the PR template.

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.

↩ Usually asked after

More in Exception Handling

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me