Module 6 · 6. Errors, Resources, Diagnostics, and Reliability · Lesson 16 of 24
Exceptions, Result Types, and Failure Boundaries
What you will be able to do
Choose an explicit outcome for ordinary absence or invalid input, keep a useful exception when an operation cannot fulfill its contract, and decide which layer is responsible for handling it. You will run a complete order-lookup simulation, trace each branch, and prove which failures are deliberately allowed to escape.
Start with the contract, not with a catch block
Our lookup has three ordinary answers: an order was found, no order exists, or the supplied text is not a usable identifier. A store outage and broken stored data are different: the application could not obtain a trustworthy answer. Reporting either as “not found” would tell a lie about the order.
Expected does not mean harmless, and unexpected does not mean rare. A product can explicitly model a declined payment as an ordinary outcome even if it is important. An infrastructure failure can happen frequently and still prevent a lookup from meeting its contract. Decide what callers can reasonably branch on, then document it.
Catch an exception where there is a concrete action: recovery, a deliberate boundary translation, or a final response. Prefer explicit checks or Try-style APIs for routine invalid input. Preserve the original cause when translating an exception. Microsoft: exception practices.
The boundary map for this lesson
- Text input adapter: rejects malformed or empty GUIDs and produces InvalidId without calling the repository
- Lookup service: retains the original
Task<Order?> FindOrderAsync(Guid, CancellationToken)contract; null still means no matching order
- Repository boundary: converts a known DatabaseTimeoutException into OrderStoreUnavailableException and keeps the original exception as InnerException
- Result adapter: turns a successful nullable lookup into Found or Missing; it does not convert all failures into result values
- Console boundary: chooses safe display text for the simulated failures it understands; a programming defect still escapes
This is one chosen application contract, not a rule that every project needs a result library. The nested outcome records merely make the three branches visible. The original nullable lookup remains useful when only found-or-absent is an ordinary answer; the additional adapter avoids silently breaking its callers.
Run the complete boundary simulation
Create a folder named FailureBoundary and place these two files in it. Use a .NET 10 SDK. Run dotnet run --project FailureBoundary.csproj -c Release. Everything happens in memory: no database, HTTP call, email, payment, or file operation occurs. The fake repository uses Task.CompletedTask to keep the async signature; it does not demonstrate real asynchronous I/O or scheduling.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
if (args.Contains("--self-test"))
{
await Checks.RunAsync();
return;
}
var repository = new FakeOrderRepository();
var service = new OrderService(repository);
foreach (string input in new[] { Ids.Found.ToString(), Ids.Missing.ToString(),
"not-an-id", Ids.Timeout.ToString(), Ids.Malformed.ToString() })
{
Console.WriteLine(await Boundary.DescribeAsync(service, input, CancellationToken.None));
}
using var canceled = new CancellationTokenSource();
canceled.Cancel();
Console.WriteLine(await Boundary.DescribeAsync(service, Ids.Found.ToString(), canceled.Token));
public sealed record Order(Guid Id, int ItemCount);
// A tiny application-specific outcome model, not a general-purpose result framework.
public abstract record LookupResult
{
private LookupResult() { }
public sealed record Found(Order Order) : LookupResult;
public sealed record Missing : LookupResult;
public sealed record InvalidId : LookupResult;
}
public sealed class OrderService(FakeOrderRepository repository)
{
// The original lesson's nullable API contract is retained:
// null means no order; timeout is translated; other exceptions propagate.
public async Task<Order?> FindOrderAsync(Guid id, CancellationToken cancellationToken)
{
try
{
return await repository.FindAsync(id, cancellationToken);
}
catch (DatabaseTimeoutException ex)
{
throw new OrderStoreUnavailableException($"Order lookup failed for {id}.", ex);
}
}
// A separate input adapter gives callers explicit ordinary outcomes.
public async Task<LookupResult> LookupAsync(string? input, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (!Guid.TryParse(input, out Guid id) || id == Guid.Empty)
return new LookupResult.InvalidId();
Order? order = await FindOrderAsync(id, token);
return order is null ? new LookupResult.Missing() : new LookupResult.Found(order);
}
}
public static class Boundary
{
public static async Task<string> DescribeAsync(OrderService service, string? input,
CancellationToken token)
{
try
{
return await service.LookupAsync(input, token) switch
{
LookupResult.Found found => $"found: {found.Order.ItemCount} items",
LookupResult.Missing => "not found",
LookupResult.InvalidId => "invalid id",
_ => throw new InvalidOperationException("Unrecognized lookup outcome.")
};
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
return "canceled by caller";
}
catch (OrderStoreUnavailableException ex)
{
// Diagnostic detail is safe here only because every value is a local fixture.
return $"unavailable: cause={ex.InnerException!.GetType().Name}";
}
catch (MalformedOrderDataException)
{
return "invalid store data: investigate";
}
}
}
public static class Ids
{
public static Guid Found { get; } = Guid.Parse("00000000-0000-0000-0000-000000000001");
public static Guid Missing { get; } = Guid.Parse("00000000-0000-0000-0000-000000000002");
public static Guid Timeout { get; } = Guid.Parse("00000000-0000-0000-0000-000000000003");
public static Guid Malformed { get; } = Guid.Parse("00000000-0000-0000-0000-000000000004");
public static Guid Bug { get; } = Guid.Parse("00000000-0000-0000-0000-000000000005");
public static Guid ForeignCancel { get; } = Guid.Parse("00000000-0000-0000-0000-000000000006");
}
public sealed class FakeOrderRepository
{
public int Calls { get; private set; }
public async Task<Order?> FindAsync(Guid id, CancellationToken token)
{
token.ThrowIfCancellationRequested();
Calls++;
await Task.CompletedTask; // Keeps the sample's async contract without external I/O.
if (id == Ids.Timeout) throw new DatabaseTimeoutException("Fixture timeout.");
if (id == Ids.Malformed) throw new MalformedOrderDataException("Fixture data has no item count.");
if (id == Ids.Bug) throw new InvalidOperationException("Fixture programming defect.");
if (id == Ids.ForeignCancel) throw new OperationCanceledException("Unrelated cancellation.");
return id == Ids.Found ? new Order(id, 3) : null;
}
}
public sealed class DatabaseTimeoutException(string message) : Exception(message);
public sealed class OrderStoreUnavailableException(string message, Exception inner)
: Exception(message, inner);
public sealed class MalformedOrderDataException(string message) : Exception(message);
public static class Checks
{
public static async Task RunAsync()
{
var repository = new FakeOrderRepository();
var service = new OrderService(repository);
Assert(await service.FindOrderAsync(Ids.Missing, default) is null, "legacy missing is null");
Assert(await service.LookupAsync(Ids.Found.ToString(), default)
is LookupResult.Found { Order.ItemCount: 3 }, "found carries order");
Assert(await service.LookupAsync(Ids.Missing.ToString(), default)
is LookupResult.Missing, "missing is explicit");
int calls = repository.Calls;
foreach (string? value in new[] { null, "", "bad", Guid.Empty.ToString() })
Assert(await service.LookupAsync(value, default) is LookupResult.InvalidId,
"invalid input has a result");
Assert(repository.Calls == calls, "invalid input never calls repository");
var wrapped = await ThrowsAsync<OrderStoreUnavailableException>(
() => service.LookupAsync(Ids.Timeout.ToString(), default));
Assert(wrapped.InnerException is DatabaseTimeoutException, "cause retained");
Assert(wrapped.Message.Contains(Ids.Timeout.ToString(), StringComparison.Ordinal), "operation context retained");
await ThrowsAsync<MalformedOrderDataException>(
() => service.LookupAsync(Ids.Malformed.ToString(), default));
await ThrowsAsync<InvalidOperationException>(
() => Boundary.DescribeAsync(service, Ids.Bug.ToString(), default));
await ThrowsAsync<OperationCanceledException>(
() => Boundary.DescribeAsync(service, Ids.ForeignCancel.ToString(), default));
using var canceled = new CancellationTokenSource();
canceled.Cancel();
calls = repository.Calls;
var cancellation = await ThrowsAsync<OperationCanceledException>(
() => service.LookupAsync("bad", canceled.Token));
Assert(cancellation.CancellationToken == canceled.Token, "token identity preserved");
Assert(repository.Calls == calls, "pre-cancellation does no repository work");
Assert(await Boundary.DescribeAsync(service, Ids.Found.ToString(), canceled.Token)
== "canceled by caller", "boundary reports caller cancellation");
Console.WriteLine("PASS: FailureBoundary semantic checks");
}
private static void Assert(bool condition, string label)
{
if (!condition) throw new Exception($"Assertion failed: {label}");
}
private static async Task<T> ThrowsAsync<T>(Func<Task> action) where T : Exception
{
try { await action(); }
catch (T ex) { return ex; }
throw new Exception($"Expected {typeof(T).Name}.");
}
}Expected output
found: 3 items not found invalid id unavailable: cause=DatabaseTimeoutException invalid store data: investigate canceled by caller
Work through the six calls
- Found: identifier 1 reaches the repository and produces an order with three items. Found carries that value, so the display layer does not need a second lookup
- Missing: identifier 2 returns null from the preserved service API. The input adapter converts this to Missing. Nothing failed in the repository fixture
- Invalid: “not-an-id” becomes InvalidId before repository access. The semantic check snapshots Calls to prove no lookup occurred
- Timeout: identifier 3 throws a fixture DatabaseTimeoutException. Only the service boundary wraps it. The display reads the cause type from InnerException. A real public response should not expose internal exception details
- Malformed: identifier 4 represents corrupt store data. It is not a bad user identifier and it is not absence. This application reports an investigation-needed failure rather than pretending it found nothing
- Canceled: the final token is canceled before the call. The adapter observes it before parsing or lookup, so cancellation wins even over invalid input in this chosen contract
The cancellation catch implements a chosen response policy: report “canceled by caller” when the supplied token is currently requested. That condition proves request state, not the cause of the caught exception. An OperationCanceledException can carry a token; inspect the operation’s documented token behavior when attribution matters. Microsoft: OperationCanceledException. The fixture’s unrelated-cancellation case uses an uncanceled caller token and must escape. If caller cancellation and an unrelated cancellation occur together, this simple filter still chooses the caller-canceled response. A system that must distinguish those causes needs a stricter documented policy; exception messages alone do not establish attribution.
Run dotnet run --project FailureBoundary.csproj -c Release -- --self-test. Expected output is PASS: FailureBoundary semantic checks. Those checks also prove that an unexpected InvalidOperationException is not relabeled as absence or a store outage. The helper throws on a failed assertion, making a wrong result visible to a test runner.
Rethrow without erasing where the failure began
Inside a catch, throw; preserves the exception’s earlier stack information. throw ex; restarts the reported stack at the rethrow point. Wrapping creates a different exception, so keep the cause in InnerException. A filter selects a catch only when its condition is true. Microsoft: exception-handling statements.
In a separate RethrowTrace folder, use the same project settings and this complete Program.cs. The second path is an intentionally incorrect rethrow for comparison. NoInlining keeps the Origin frame available for the comparison; do not depend on exact line numbers, file paths, or a full stack string in a portable assertion.
using System.Runtime.CompilerServices;
Exception preserved = Capture(true);
Exception reset = Capture(false);
bool preservedOrigin = HasOrigin(preserved);
bool resetOrigin = HasOrigin(reset);
if (args.Contains("--self-test"))
{
if (!preservedOrigin || resetOrigin)
throw new Exception("The rethrow origin checks failed.");
Console.WriteLine("PASS: RethrowTrace semantic checks");
}
else
{
Console.WriteLine($"throw; keeps origin: {preservedOrigin}");
Console.WriteLine($"throw ex; keeps origin: {resetOrigin}");
}
static bool HasOrigin(Exception error) =>
error.StackTrace?.Contains(nameof(Origin), StringComparison.Ordinal) == true;
static Exception Capture(bool preserve)
{
try { Relay(preserve); }
catch (InvalidOperationException ex) { return ex; }
throw new Exception("Expected the fixture to throw.");
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void Relay(bool preserve)
{
try { Origin(); }
catch (InvalidOperationException ex)
{
if (preserve) throw;
// Deliberate anti-example: do not copy this into a recovery path.
#pragma warning disable CA2200
throw ex;
#pragma warning restore CA2200
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void Origin() => throw new InvalidOperationException("Fixture failure.");Expected output
throw; keeps origin: True throw ex; keeps origin: False
Run the same project with -- --self-test to check the two origin conditions. Expected output is PASS: RethrowTrace semantic checks. This tiny experiment answers a precise question: whether the original throwing method remains represented. It does not prove that every optimization, runtime, or reporting tool prints identical stacks.
Solved application: define an HTTP lookup contract
Problem. A client-facing lookup can encounter invalid input, HTTP 404, a dependency timeout, and malformed response data. Choose ordinary outcomes and failure behavior. No actual request is needed to solve the design.
One defensible contract. Validate the identifier before sending. Map a documented 404-for-missing contract to Missing. Translate a known dependency timeout to a store-unavailable failure with its cause retained. Treat malformed successful response data as a dependency-contract failure: “there is no order” has not been established. Preserve caller cancellation as cancellation. If the remote API instead uses 404 for hidden authorization failures, do not assume it proves absence; use that API’s documented policy.
Why this choice? The caller can fix malformed text and can act on documented absence. It cannot safely repair corrupt data by guessing. The UI can offer a fresh lookup after an outage, but a retry policy belongs to the operation: a read-only lookup and a charge have different duplicate-effect risks. No automatic retry is included here.
Solved debugging exercise: the disappearing outage
Problem. A developer surrounds FindOrderAsync with catch (Exception) { return null; }. The website now shows “order not found” during a timeout. What broke, and how would you prove the repair?
Solution. The catch changed null from a reliable absence signal into an ambiguous absence-or-failure signal. Remove it, retain the narrow timeout translation, and let the response boundary choose an unavailable response. Keep a test asserting null for the missing fixture and a different test asserting OrderStoreUnavailableException with DatabaseTimeoutException inside for the timeout fixture. A single “did not crash” test would miss this contract violation.
A bounded analogy
Think of a library desk. “We have no copy with that catalog number” is a usable answer. “The catalog system is unavailable” is an inability to answer. A desk worker who turns both into “no copy” has made the system look calm while misleading the reader. The limit of the analogy: C# does not decide which business outcomes deserve values; your API contract does.
Interview checks with answers
- Where should translation happen? At the boundary that can replace a dependency-specific failure with an application-level meaning. In this fixture, the service translates one known repository timeout and retains its cause
- Why not catch everything in every layer? Each extra catch needs an actual responsibility. In this program, a catch-all would swallow the deliberately injected programming defect or mislabel unrelated cancellation
- Where should logging happen? Choose an owning reporting boundary so one failed operation does not become several duplicate incident events. Add useful operation context there, excluding credentials or private response bodies
- What does this example not establish? It has no database driver, HTTP framework, durable transaction, retry engine, or real timeout clock. Those integrations must be tested against their own contracts