Error Handling in Clean Code: Exceptions, the Try Pattern and the Result Pattern
Use the Try pattern or Result<T> for expected business outcomes (out of stock, invalid coupon, payment declined), argument exceptions for bugs and exceptions with added context for infrastructure failures, and catch them only at the application's boundary. We clean up the return codes, the static LastError and the empty catch blocks of the legacy OrderService with these three tools; in our measurement a thrown exception is about 1,165 times slower than TryParse.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First: Three Cancellations, Two Falses
Using the legacy OrderService, we place an order for Alan and then cancel it. Next we try to cancel the same order once more, and finally an order that doesn't exist at all:
var cart = new Dictionary<string, int> { ["978-0441013593"] = 1 }; // Dune
int id = service.ProcessData(2, cart, null, false, false, 1);
Console.WriteLine(id);
Console.WriteLine(service.CancelOrder(id, refund: true, notify: false));
Console.WriteLine(service.CancelOrder(id, refund: true, notify: false));
Console.WriteLine(service.CancelOrder(4242, refund: true, notify: false));
Console.WriteLine($"LastError: {OrderService.LastError ?? "null"}");
Which lines print True and which print False? More importantly: how does the calling code find out why a call that returned False failed?
Return Codes and a Static LastError
1000
True
False
False
LastError: null
The second cancellation was refused because the order was already cancelled, the third because the order wasn't found. The calling code can't tell the two apart: both are false, and LastError is empty in both cases. Inside CancelOrder there are six separate reasons that lead to false (no order, shipped, already cancelled, failed payment, refund rejected, any exception), and only one of them writes anything to LastError.
ProcessData is even busier: -1 empty cart, -2 no such book, -3 out of stock, -4 invalid coupon, -5 payment declined, -6 customer blocked, -7 no such customer, -99 "something happened". A positive number is the order id. When I tried it with a customer who has no payment token:
Value cannot be null. (Parameter 'cardToken')
-99 LastError: null
The exception message went to the console rather than a log, and only -99 reached the caller. Call it with an unknown shipping type (t = 4) and the opposite happens; the throw new Exception("hata") inside KargoUcretiHesapla escapes without hitting any try:
System.Exception: hata
The first recommendation in the error handling chapter of Clean Code (2008; second edition 2025) by Robert C. Martin (Uncle Bob) answers exactly this picture: use exceptions instead of return codes. Return codes force the caller to write an if after every line, business logic and error checks get tangled, and forgetting a check means silently doing the wrong thing. The static LastError makes it worse: every OrderService instance and every thread shares the same field, so two concurrent requests can overwrite each other's error message. But "make everything an exception" isn't the right answer either; first we need to separate the errors.
Which Error Deserves Which Tool?
Once you split all the failures in the legacy code into three groups, the decision gets easier:
| Situation | Kind | Tool |
|---|---|---|
| Empty cart, invalid coupon, not enough stock, payment declined | Expected business outcome | Result<T> or the Try pattern |
t = 4, a null card token, a negative quantity | Programming error, a bug | Argument exceptions, fail fast |
| Payment provider not answering, disk full | Infrastructure failure | Exception, with added context; catch at the boundary |
| Customer or order not found | Depends on context | null (Find), exception (Get) or Result |
Expected business outcomes are part of the normal flow: every day hundreds of customers put a sold-out book in their cart. Using exceptions for these is both slow and misleading, because an exception says "I didn't expect this to happen". Programming errors, on the other hand, should not be caught; the program should fail early and loudly so the bug gets found. The "Best practices for exceptions" page on Microsoft Learn makes the same distinction: handle frequently expected conditions without exceptions, and leave the truly exceptional ones to exceptions.
The Try Pattern for Expected Conditions
.NET's own APIs (Application Programming Interfaces) apply this distinction in dozens of places: int.Parse and int.TryParse, the Dictionary<TKey, TValue> indexer and TryGetValue, ConcurrentDictionary.TryAdd, TryWrite on a Channel writer. The Framework Design Guidelines (the .NET library design book by Krzysztof Cwalina and Brad Abrams, also published on Microsoft Learn) name two patterns for it. In Tester-Doer you ask first (ICollection<T>.IsReadOnly) and then act (Add). In Try-Parse the two merge into one method: it starts with the Try prefix, returns a bool, hands back the result through an out parameter; and every Try method should have an exception-throwing counterpart.
I measured why the difference matters. Two methods that try to turn an invalid coupon code into a number, with BenchmarkDotNet 0.15.8 on an Apple M5:
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|-------------- |-------------:|-----------:|-----------:|---------:|--------:|-------:|----------:|------------:|
| WithTryParse | 1.902 ns | 0.0030 ns | 0.0027 ns | 1.00 | 0.00 | - | - | NA |
| WithException | 2,215.906 ns | 43.2412 ns | 42.4687 ns | 1,164.91 | 21.72 | 0.0610 | 512 B | NA |
Each exception thrown and caught costs about 2.2 microseconds and a 512-byte allocation. Negligible for a single request, decisive in an import job running tens of thousands of validations per second. The legacy CheckStock both checked stock and decremented it, and its name never mentioned that side effect. Let's write the same job by the Try-Parse rules:
public sealed class Inventory(Dictionary<string, int> stock)
{
public int Available(string isbn) => stock.GetValueOrDefault(isbn);
// Try-Parse pattern: an expected failure is returned as a bool
public bool TryReserve(string isbn, int quantity, [NotNullWhen(true)] out StockReservation? reservation)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
reservation = null;
if (Available(isbn) < quantity)
return false;
stock[isbn] -= quantity;
reservation = new StockReservation(isbn, quantity);
return true;
}
// Guideline: every Try method gets an exception-throwing counterpart
public StockReservation Reserve(string isbn, int quantity) =>
TryReserve(isbn, quantity, out StockReservation? reservation)
? reservation
: throw new InsufficientStockException(isbn, quantity, Available(isbn));
}
Not enough stock, available: 3
InsufficientStockException: Only 3 of 978-0141439518 left, 5 requested.
Two details matter. The [NotNullWhen(true)] attribute tells the compiler "when this returns true, reservation is not null", so the caller can read reservation.Quantity without a warning. A negative quantity, however, is not in the same category as "not enough stock": it's a bug, so even the Try method rejects it with ArgumentOutOfRangeException. In the guideline's terms, the scope of the Try must be defined narrowly; every failure outside that definition is still an exception.
The Result Pattern: Turning the Error into a Type
The Try pattern works nicely when there is a single reason for failure. Placing an order has seven different expected failures, though, and the screen should react differently to each. Instead of -3, let's make the error itself a type:
public abstract record Error(string Code, string Message);
public sealed record CustomerNotFound(int CustomerId)
: Error("order.customer_not_found", $"Customer {CustomerId} was not found.");
public sealed record OutOfStock(string Title, int Requested, int Available)
: Error("order.out_of_stock", $"Only {Available} of '{Title}' left, {Requested} requested.");
public sealed record InvalidCoupon(string CouponCode)
: Error("order.invalid_coupon", $"Coupon '{CouponCode}' is not valid.");
public sealed record PaymentDeclined(string Reason)
: Error("order.payment_declined", $"Payment was declined: {Reason}.");
CustomerBlocked, EmptyCart and BookNotFound follow the same shape. The Result<T> that carries them is a small class:
public sealed class Result<T>
{
private readonly T? _value;
private Result(T value) => (_value, IsSuccess) = (value, true);
private Result(Error error) => Error = error;
public bool IsSuccess { get; }
public Error? Error { get; }
public T Value => IsSuccess ? _value! : throw new InvalidOperationException($"Result has no value: {Error!.Code}.");
public static implicit operator Result<T>(T value) => new(value);
public static implicit operator Result<T>(Error error) => new(error);
public TOut Match<TOut>(Func<T, TOut> onSuccess, Func<Error, TOut> onFailure) =>
IsSuccess ? onSuccess(_value!) : onFailure(Error!);
}
Thanks to the implicit operators, both failure and success come back from OrderPlacement with a plain return:
public async Task<Result<PlacedOrder>> PlaceAsync(PlaceOrderRequest request, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
if (!customers.TryGetValue(request.CustomerId, out Customer? customer))
return new CustomerNotFound(request.CustomerId);
if (customer.IsBlocked)
return new CustomerBlocked(customer.Id);
if (request.Lines.Count == 0)
return new EmptyCart();
// ... book and stock checks, coupon, total
PaymentOutcome outcome = await payments.ChargeAsync(customer.CardToken, total, ct);
if (outcome is PaymentOutcome.Declined declined)
return new PaymentDeclined(declined.Reason);
foreach (CartLine line in request.Lines)
inventory.Reserve(line.Isbn, line.Quantity);
return new PlacedOrder(_nextOrderId++, total, ((PaymentOutcome.Approved)outcome).TransactionId);
}
Calling it with seven different requests and printing each result with Match:
OK #1000 801.90 TRY (TX0001)
FAIL order.customer_not_found: Customer 9 was not found.
FAIL order.customer_blocked: Customer 2 is blocked.
FAIL order.empty_cart: The cart is empty.
FAIL order.out_of_stock: Only 1 of 'Emma' left, 7 requested.
FAIL order.invalid_coupon: Coupon 'SUMMER99' is not valid.
FAIL order.payment_declined: Payment was declined: Do not honor.
The method's signature is now honest: Task<Result<PlacedOrder>> says "this call can fail", and the caller can't reach the order without opening the result. The error itself carries information, too; the screen can read the Available value of OutOfStock and make the user an offer:
string screen = failed.Error switch
{
null => "Thank you!",
OutOfStock e => $"Only {e.Available} left. Reduce the quantity?",
InvalidCoupon => "Check the coupon code.",
PaymentDeclined => "Try another card.",
_ => failed.Error.Message
};
Only 1 left. Reduce the quantity?
The _ arm here is a weakness: when a new error type is added, the compiler won't point at this switch, because Error is an open hierarchy. The union types coming with C# 15 close that gap; the compiler warns about a switch that doesn't handle every case type.
Don't Return Null, Don't Pass Null
The legacy GetOrders returns null when the customer has no orders; every caller has to know that and check for it, and the one who forgets gets a NullReferenceException in a foreach. Two of Martin's rules apply here: don't return null, don't pass null. For methods returning a collection the answer is simple, return an empty collection: return orders.Where(o => o.CustomerId == customerId).ToList(); is a valid result with zero elements too.
For a single object, if "not there" is a genuinely possible answer, write it into the type. .NET has an established naming convention: Find / TryGet says "I might not find it" and returns Order?, while Get says "it should be here" and throws if it isn't. With Nullable Reference Types (NRT) on, the compiler enforces the contract:
Order? order = repository.FindOrder(4242);
Console.WriteLine(order.Total);
Program.cs(26,19): warning CS8602: Dereference of a possibly null reference.
Ignore the warning and run it, and you get the predicted result:
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<Main>$(String[] args) in .../Program.cs:line 26
The difference is that in the legacy code this information isn't written anywhere, while here it surfaces at compile time. When "not there" has behavior of its own (a guest customer, an open-ended end date), Special Case (a pattern from Martin Fowler's Patterns of Enterprise Application Architecture) or Null Object is cleaner than null; the Solution Patterns series post "Sentinel Values: The Hidden Cost of 0, -1, "1900-01-01" and 9999-12-31" shows Special Case with a real example. The details of NRT are in the C# Advanced series post "Nullable Reference Types in C# in Depth: NotNullWhen, MaybeNull and API Design".
Where to Catch Exceptions: Boundaries and Context
When the payment provider doesn't answer, it's no longer a business outcome but an infrastructure failure. OrderPlacement can't recover from it; the best it can do is let the exception continue on its way. The layer that wraps the exception, the payment adapter, should add context to it: which amount, which operation. The original exception stays as the InnerException:
PaymentGatewayException: Charging 495.00 TRY failed: provider did not answer.
inner: TimeoutException: The operation has timed out.
Dune stock: 9
The last line matters too: stock wasn't decremented because the payment failed. The cheapest way to follow Microsoft Learn's advice to restore state after an exception is to put the side effect after the success.
If you need to catch an exception and throw it again, write throw;, not throw ex;. Two versions of the same method:
public void SaveWithThrowEx(int id)
{
try
{
WriteToDisk(id);
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"log: {ex.Message}");
throw ex;
}
}
The other version is identical, only its last line is throw;. The compiler already sees the difference:
Program.cs(45,17): warning CA2200: Re-throwing caught exception changes stack information (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2200)
The two stack traces side by side:
throw ex:
at Bookstore.Orders.OrderRepository.SaveWithThrowEx(Int32 id) in .../Program.cs:line 45
at Program.<Main>$(String[] args) in .../Program.cs:line 7
throw:
at Bookstore.Orders.OrderRepository.WriteToDisk(Int32 id) in .../Program.cs:line 62
at Bookstore.Orders.OrderRepository.SaveWithThrow(Int32 id) in .../Program.cs:line 53
at Program.<Main>$(String[] args) in .../Program.cs:line 17
throw ex; erased the WriteToDisk line where the error actually happened. For someone reading the production log at 3 a.m., that difference is the difference between five minutes and an hour.
So where is the exception finally caught? At the edge of the application: the outermost layer of the HTTP (Hypertext Transfer Protocol) request, the background job, the message handler. In ASP.NET Core this has two layers. Expected errors are translated from Result into the HTTP contract at the endpoint, while unexpected ones fall through to an IExceptionHandler:
app.MapPost("/orders", async (PlaceOrderRequest request, OrderPlacement placement, CancellationToken ct) =>
{
Result<PlacedOrder> result = await placement.PlaceAsync(request, ct);
return result.Match(
order => Results.Created($"/orders/{order.OrderId}", order),
error => error.ToProblem());
});
public static class ErrorMapping
{
// Domain error → HTTP contract (only at the boundary)
public static IResult ToProblem(this Error error)
{
int status = error switch
{
CustomerNotFound or BookNotFound => StatusCodes.Status404NotFound,
OutOfStock => StatusCodes.Status409Conflict,
_ => StatusCodes.Status422UnprocessableEntity
};
return Results.Problem(
title: error.Message,
statusCode: status,
extensions: new Dictionary<string, object?> { ["code"] = error.Code });
}
}
PaymentGatewayExceptionHandler logs the exception and returns 503. Three requests with curl:
HTTP/1.1 201 Created
Location: /orders/1000
{"orderId":1000,"total":198.00,"transactionId":"TX0001"}
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Only 2 of 'Emma' left, 7 requested.","status":409,"code":"order.out_of_stock","traceId":"00-127e77baf37832a6f0f6c091e0b65431-54b80ce55d983883-00"}
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Payment service is temporarily unavailable.","status":503,"traceId":"00-5e7c94686e72956ea50e7fb9192cb39e-8d0c1fc1dea10fc5-00"}
The domain knows nothing about HTTP status codes, and the endpoint knows nothing about business rules. IExceptionHandler, AddProblemDetails and choosing status codes are in the ASP.NET Core Web API series post "Global Error Handling in ASP.NET Core: Exception Middleware and ProblemDetails"; why error responses shouldn't leak internal details is in the OWASP (Open Worldwide Application Security Project) series post "OWASP A10: Mishandling of Exceptional Conditions — Fail Closed and ProblemDetails".
Common Pitfalls
The most common one is swallowing exceptions. The legacy file has five of them, and they all show up once CA1031 (CA: .NET's Code Analysis rules) is set to warning in .editorconfig:
OrderService.cs(121,25): warning CA1031: Modify 'ProcessData' to catch a more specific allowed exception type, or rethrow the exception (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031)
OrderService.cs(235,13): warning CA1031: Modify 'CancelOrder' to catch a more specific allowed exception type, or rethrow the exception (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031)
OrderService.cs(427,21): warning CA1031: Modify 'ImportBooks' to catch a more specific allowed exception type, or rethrow the exception (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031)
OrderService.cs(430,13): warning CA1031: Modify 'ImportBooks' to catch a more specific allowed exception type, or rethrow the exception (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031)
OrderService.cs(457,13): warning CA1031: Modify 'SendMail' to catch a more specific allowed exception type, or rethrow the exception (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031)
The per-line catch { } in ImportBooks is the sneakiest. Load a CSV (Comma-Separated Values) with "ten" in the stock column and the method returns 2 with an empty LastError; nobody learns that a book was left out. Turn on CA2201 as well and the throw new Exception("hata") in KargoUcretiHesapla shows up: System.Exception is not sufficiently specific. In the fix, line errors come back as a report, while failing to read the file at all stays an exception:
public sealed record ImportError(int LineNumber, string Reason);
public sealed record ImportReport(IReadOnlyList<Book> Imported, IReadOnlyList<ImportError> Errors);
public static class BookCsvImporter
{
// Line errors are expected outcomes; an unreadable file goes to the caller as an exception
public static ImportReport Import(string path) => Parse(File.ReadLines(path));
public static ImportReport Parse(IEnumerable<string> lines)
{
List<Book> imported = [];
List<ImportError> errors = [];
int lineNumber = 0;
foreach (string line in lines)
{
lineNumber++;
string[] parts = line.Split(';');
if (parts.Length < 5)
errors.Add(new(lineNumber, $"Expected at least 5 columns, found {parts.Length}."));
else if (!decimal.TryParse(parts[3], NumberStyles.Number, CultureInfo.InvariantCulture, out decimal price))
errors.Add(new(lineNumber, $"Price '{parts[3]}' is not a number."));
else if (!int.TryParse(parts[4], CultureInfo.InvariantCulture, out int _))
errors.Add(new(lineNumber, $"Stock '{parts[4]}' is not a whole number."));
else
imported.Add(new Book(parts[0], parts[1], price));
}
return new ImportReport(imported, errors);
}
}
Imported: 2
line 2: Stock 'ten' is not a whole number.
FileNotFoundException: missing.csv
The second pitfall is logging and rethrowing at every layer. The repository logs, the service logs, the controller logs; a single error appears three times in the log, and nobody can tell which one is the real one. The log is written where the exception is actually handled, at the boundary.
The third is treating OperationCanceledException as an error. A request cancelled because the user closed the page is not a bug; catching it with catch (Exception) and returning 500, or logging it at error level, produces alert noise.
The fourth is spreading Result everywhere: when every method from the repository to the controller returns Result<T>, the code fills up with if (!result.IsSuccess) return result.Error; lines, the C# version of Go's repeated if err != nil. Use Result where the business decision is made, and don't wrap infrastructure failures in Result.
When Not to Use It
Think twice before using the Result pattern in a library's public API. The general contract of the .NET ecosystem is exceptions and the Try pattern; your users would have to learn your Result<T> and your error hierarchy. Within an application's own domain, that cost is low.
Don't write Try or Result for rare, unrecoverable situations. If the configuration file is corrupt, the application should fail at startup; a method like TryLoadConfiguration leaves its caller with an impossible decision.
If there is a single reason for failure, you don't need the whole Result machinery. A bool + out like TryReserve, or Find + null, says the same thing with less code.
In a short-lived script or a one-off migration tool you don't need these subtleties either; stopping with an exception at the first error is often the right behavior.
A Small Experiment
★☆☆ Rewrite the legacy CancelOrder so that it returns Result<CancelledOrder>. Create an error type for each return false; delete the empty catch block and decide where an exception thrown during the refund should go.
★★☆ Add a payment scenario to OrderPlacement that is cancelled through a CancellationToken. At which layer should OperationCanceledException be caught, and which status code should the ASP.NET Core boundary return for it?
★★★ Find a way to remove the _ arm over the Error hierarchy: extend the Match method to take a separate Func for each error type, and show that the build breaks when a new error is added. What does this design cost?
In this post we separated the errors and wrapped the payment provider's exception in our own type. The next post generalizes that wrapping: boundaries for the payment SDK (Software Development Kit), the Dictionary<string, object> cache and the email library the legacy code uses directly, and learning a new library's behavior through learning tests.