Error Handling and Result Types
"Error handling is the gap between 'it works' and 'it works correctly.' Most software lives in that gap — handling the happy path and hoping the rest never happens."
Where Convention Failures Become Production Incidents
Every domain in this series has the same story: convention is documented, convention is enforced with tests, convention drifts, convention fails silently. In validation, a missing validator means bad data enters the system. In DDD, a missing repository means a runtime exception. In API contracts, a missing serialization convention means a malformed response.
But error handling is different. Error handling failures are invisible until they are catastrophic. A swallowed exception does not fail a test. An unmatched Result<T> case does not fail a build. A missing error path does not produce a warning. The code compiles, the tests pass, the application runs — until a user hits the one path that nobody handled, and the response is 500 Internal Server Error with no context, no structured error, and no way to diagnose what happened.
Error handling is where Convention's double cost is most dangerous, because the thing that slips through is not missing validation or wrong naming — it is missing behavior. The system does nothing where it should do something. And nothing is the hardest failure to detect.
Era 1: Code -- Try/Catch Everywhere
In the beginning, error handling was try/catch. Written by hand. Placed wherever the developer thought an error might occur. Which was sometimes everywhere, sometimes nowhere, and usually inconsistent.
// Era 1: Code — try/catch in every layer
public class OrderController : Controller
{
private readonly OrderService _orderService;
private readonly PaymentGateway _paymentGateway;
private readonly ILogger<OrderController> _logger;
public OrderController(
OrderService orderService,
PaymentGateway paymentGateway,
ILogger<OrderController> logger)
{
_orderService = orderService;
_paymentGateway = paymentGateway;
_logger = logger;
}
[HttpPost]
public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
{
try
{
// Validate — might throw ArgumentException, might not
if (request.Items == null || request.Items.Count == 0)
{
return BadRequest("Order must have items");
}
Order order;
try
{
order = _orderService.Create(request);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create order");
return StatusCode(500, "Failed to create order");
}
// Process payment — might throw HttpRequestException,
// TimeoutException, PaymentDeclinedException...
try
{
var paymentResult = _paymentGateway.Charge(
order.Total, request.PaymentMethod);
if (!paymentResult.Success)
{
// Is this a 400? A 402? A 422? Nobody agreed.
return BadRequest("Payment failed");
}
}
catch (TimeoutException)
{
// Payment timed out — did it charge or not?
// We don't know. Return 500 and hope someone investigates.
return StatusCode(500, "Payment processing timed out");
}
catch (Exception ex)
{
_logger.LogError(ex, "Payment failed for order {OrderId}", order.Id);
return StatusCode(500, "Payment error");
}
return Ok(order);
}
catch (Exception)
{
// The catch-all. Swallows everything.
// No logging here — developer forgot.
// No structured error — just a status code.
return StatusCode(500);
}
}
}// Era 1: Code — try/catch in every layer
public class OrderController : Controller
{
private readonly OrderService _orderService;
private readonly PaymentGateway _paymentGateway;
private readonly ILogger<OrderController> _logger;
public OrderController(
OrderService orderService,
PaymentGateway paymentGateway,
ILogger<OrderController> logger)
{
_orderService = orderService;
_paymentGateway = paymentGateway;
_logger = logger;
}
[HttpPost]
public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
{
try
{
// Validate — might throw ArgumentException, might not
if (request.Items == null || request.Items.Count == 0)
{
return BadRequest("Order must have items");
}
Order order;
try
{
order = _orderService.Create(request);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create order");
return StatusCode(500, "Failed to create order");
}
// Process payment — might throw HttpRequestException,
// TimeoutException, PaymentDeclinedException...
try
{
var paymentResult = _paymentGateway.Charge(
order.Total, request.PaymentMethod);
if (!paymentResult.Success)
{
// Is this a 400? A 402? A 422? Nobody agreed.
return BadRequest("Payment failed");
}
}
catch (TimeoutException)
{
// Payment timed out — did it charge or not?
// We don't know. Return 500 and hope someone investigates.
return StatusCode(500, "Payment processing timed out");
}
catch (Exception ex)
{
_logger.LogError(ex, "Payment failed for order {OrderId}", order.Id);
return StatusCode(500, "Payment error");
}
return Ok(order);
}
catch (Exception)
{
// The catch-all. Swallows everything.
// No logging here — developer forgot.
// No structured error — just a status code.
return StatusCode(500);
}
}
}What Goes Wrong
Every developer on the team writes error handling differently. Developer A catches specific exceptions. Developer B catches Exception everywhere. Developer C doesn't catch at all and lets the global handler deal with it. Developer D catches exceptions, logs them, and then re-throws a different exception that loses the stack trace.
// Developer D's pattern — seen in every codebase
catch (Exception ex)
{
_logger.LogError(ex, "Something failed");
throw new ApplicationException("An error occurred"); // Stack trace gone
}// Developer D's pattern — seen in every codebase
catch (Exception ex)
{
_logger.LogError(ex, "Something failed");
throw new ApplicationException("An error occurred"); // Stack trace gone
}The problems compound:
- Empty catch blocks:
catch (Exception) { }— the error happened, nobody knows, the user gets stale data or a silent failure - Exceptions as control flow:
throw new NotFoundException()in a service, caught in a controller, translated to a 404. This isgotowith extra steps. - Inconsistent responses: one endpoint returns
{ "error": "Not found" }, another returns{ "message": "Resource does not exist" }, another returns plain text"404" catch (Exception) { return StatusCode(500); }— the universal silencer. Hides every possible failure behind the same opaque response.
The code compiles. The happy path works. The error paths are a minefield of inconsistency, swallowed exceptions, and missing context.
Error handling in Era 1 is not a strategy. It is an accident that happens to compile.
Era 2: Configuration -- Exception Hierarchies and Middleware
The first attempt to systematize error handling: create a taxonomy of exceptions and a centralized handler.
// Era 2: Custom exception hierarchy
public abstract class AppException : Exception
{
public string ErrorCode { get; }
public int HttpStatusCode { get; }
protected AppException(
string message, string errorCode, int httpStatusCode)
: base(message)
{
ErrorCode = errorCode;
HttpStatusCode = httpStatusCode;
}
}
public class NotFoundException : AppException
{
public NotFoundException(string entityName, object id)
: base(
$"{entityName} with id '{id}' was not found",
"NOT_FOUND",
404)
{ }
}
public class ValidationException : AppException
{
public IDictionary<string, string[]> Errors { get; }
public ValidationException(IDictionary<string, string[]> errors)
: base("Validation failed", "VALIDATION_ERROR", 422)
{
Errors = errors;
}
}
public class BusinessRuleException : AppException
{
public BusinessRuleException(string rule, string message)
: base(message, $"BUSINESS_RULE_{rule}", 409)
{ }
}
public class ForbiddenException : AppException
{
public ForbiddenException(string resource, string action)
: base(
$"Not authorized to {action} on {resource}",
"FORBIDDEN",
403)
{ }
}// Era 2: Custom exception hierarchy
public abstract class AppException : Exception
{
public string ErrorCode { get; }
public int HttpStatusCode { get; }
protected AppException(
string message, string errorCode, int httpStatusCode)
: base(message)
{
ErrorCode = errorCode;
HttpStatusCode = httpStatusCode;
}
}
public class NotFoundException : AppException
{
public NotFoundException(string entityName, object id)
: base(
$"{entityName} with id '{id}' was not found",
"NOT_FOUND",
404)
{ }
}
public class ValidationException : AppException
{
public IDictionary<string, string[]> Errors { get; }
public ValidationException(IDictionary<string, string[]> errors)
: base("Validation failed", "VALIDATION_ERROR", 422)
{
Errors = errors;
}
}
public class BusinessRuleException : AppException
{
public BusinessRuleException(string rule, string message)
: base(message, $"BUSINESS_RULE_{rule}", 409)
{ }
}
public class ForbiddenException : AppException
{
public ForbiddenException(string resource, string action)
: base(
$"Not authorized to {action} on {resource}",
"FORBIDDEN",
403)
{ }
}// Centralized exception handler middleware
public class ExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
public ExceptionHandlerMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (AppException ex)
{
_logger.LogWarning(ex,
"Application error {ErrorCode}: {Message}",
ex.ErrorCode, ex.Message);
context.Response.StatusCode = ex.HttpStatusCode;
await context.Response.WriteAsJsonAsync(new
{
error = ex.ErrorCode,
message = ex.Message,
// Add validation errors if present
errors = ex is ValidationException ve
? ve.Errors : null
});
}
catch (Exception ex)
{
// Still need the catch-all
_logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new
{
error = "INTERNAL_ERROR",
message = "An unexpected error occurred"
});
}
}
}// Centralized exception handler middleware
public class ExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
public ExceptionHandlerMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (AppException ex)
{
_logger.LogWarning(ex,
"Application error {ErrorCode}: {Message}",
ex.ErrorCode, ex.Message);
context.Response.StatusCode = ex.HttpStatusCode;
await context.Response.WriteAsJsonAsync(new
{
error = ex.ErrorCode,
message = ex.Message,
// Add validation errors if present
errors = ex is ValidationException ve
? ve.Errors : null
});
}
catch (Exception ex)
{
// Still need the catch-all
_logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new
{
error = "INTERNAL_ERROR",
message = "An unexpected error occurred"
});
}
}
}The WCF/Remoting Era
For those who remember WCF, exceptions had to be [Serializable] to cross service boundaries. The [Serializable] attribute, the SerializationInfo constructor, the GetObjectData override — 20 lines of boilerplate per exception class, all of it cargo-culted from documentation because nobody understood binary serialization.
[Serializable]
public class OrderNotFoundException : AppException
{
public OrderNotFoundException(Guid orderId)
: base($"Order {orderId} not found", "ORDER_NOT_FOUND", 404)
{ }
// Required for serialization — nobody understands why
protected OrderNotFoundException(
SerializationInfo info, StreamingContext context)
: base(info, context)
{ }
}[Serializable]
public class OrderNotFoundException : AppException
{
public OrderNotFoundException(Guid orderId)
: base($"Order {orderId} not found", "ORDER_NOT_FOUND", 404)
{ }
// Required for serialization — nobody understands why
protected OrderNotFoundException(
SerializationInfo info, StreamingContext context)
: base(info, context)
{ }
}What Improves — and What Doesn't
The exception hierarchy gives structure. Consistent error codes. Consistent response shapes. Centralized logging. This is real progress over Era 1.
But exceptions are invisible to the caller. Nothing in the method signature tells you what might go wrong:
// What does this method throw? You have to read the implementation.
// Or the documentation. Or find out in production.
public async Task<Order> GetOrderAsync(Guid id)
{
var order = await _repository.GetByIdAsync(id);
if (order == null)
throw new NotFoundException("Order", id);
if (!_authService.CanAccess(order))
throw new ForbiddenException("Order", "read");
return order;
}// What does this method throw? You have to read the implementation.
// Or the documentation. Or find out in production.
public async Task<Order> GetOrderAsync(Guid id)
{
var order = await _repository.GetByIdAsync(id);
if (order == null)
throw new NotFoundException("Order", id);
if (!_authService.CanAccess(order))
throw new ForbiddenException("Order", "read");
return order;
}The return type says Task<Order>. The actual outcomes are: Order, NotFoundException, ForbiddenException, and any number of infrastructure exceptions from the repository or auth service. The caller has to know about these exceptions — from documentation, from convention, or from experience.
Java tried to fix this with checked exceptions. That experiment is widely regarded as a failure — not because the idea was wrong, but because the mechanism was wrong. Declaring throws NotFoundException, ForbiddenException, AuthenticationException on every method in the chain becomes noise that developers suppress with throws Exception.
Exceptions are goto statements with a type system veneer. They transfer control to an unknown handler at an unknown location in the call stack. The method signature lies about its behavior.
Era 3: Convention -- The Result Pattern
The functional programming community solved this decades ago: make error paths explicit in the return type. C# adopted this pattern through libraries like OneOf, LanguageExt, and hand-rolled Result<T> types.
// Era 3: Result<T> — making error paths explicit
public abstract record OrderError;
public record OrderNotFound(Guid OrderId) : OrderError;
public record OrderValidationFailed(
IDictionary<string, string[]> Errors) : OrderError;
public record InsufficientInventory(
string ProductId, int Requested, int Available) : OrderError;
public record PaymentDeclined(string Reason) : OrderError;
public record OrderForbidden(string UserId, string Action) : OrderError;
// Application service — returns Result, never throws
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IInventoryService _inventory;
private readonly IPaymentGateway _payments;
private readonly IAuthorizationService _auth;
public OrderService(
IOrderRepository repository,
IInventoryService inventory,
IPaymentGateway payments,
IAuthorizationService auth)
{
_repository = repository;
_inventory = inventory;
_payments = payments;
_auth = auth;
}
public async Task<OneOf<Order, OrderError>> CreateOrderAsync(
CreateOrderCommand command)
{
if (!_auth.CanCreateOrders(command.UserId))
return new OrderForbidden(command.UserId, "create");
foreach (var item in command.Items)
{
var available = await _inventory
.GetAvailableAsync(item.ProductId);
if (available < item.Quantity)
return new InsufficientInventory(
item.ProductId, item.Quantity, available);
}
var order = Order.Create(command);
var paymentResult = await _payments.ChargeAsync(
order.Total, command.PaymentMethod);
if (!paymentResult.Success)
return new PaymentDeclined(paymentResult.FailureReason);
await _repository.AddAsync(order);
return order;
}
public async Task<OneOf<Order, OrderError>> GetOrderAsync(
Guid id, string userId)
{
var order = await _repository.GetByIdAsync(id);
if (order is null)
return new OrderNotFound(id);
if (!_auth.CanAccess(order, userId))
return new OrderForbidden(userId, "read");
return order;
}
}// Era 3: Result<T> — making error paths explicit
public abstract record OrderError;
public record OrderNotFound(Guid OrderId) : OrderError;
public record OrderValidationFailed(
IDictionary<string, string[]> Errors) : OrderError;
public record InsufficientInventory(
string ProductId, int Requested, int Available) : OrderError;
public record PaymentDeclined(string Reason) : OrderError;
public record OrderForbidden(string UserId, string Action) : OrderError;
// Application service — returns Result, never throws
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IInventoryService _inventory;
private readonly IPaymentGateway _payments;
private readonly IAuthorizationService _auth;
public OrderService(
IOrderRepository repository,
IInventoryService inventory,
IPaymentGateway payments,
IAuthorizationService auth)
{
_repository = repository;
_inventory = inventory;
_payments = payments;
_auth = auth;
}
public async Task<OneOf<Order, OrderError>> CreateOrderAsync(
CreateOrderCommand command)
{
if (!_auth.CanCreateOrders(command.UserId))
return new OrderForbidden(command.UserId, "create");
foreach (var item in command.Items)
{
var available = await _inventory
.GetAvailableAsync(item.ProductId);
if (available < item.Quantity)
return new InsufficientInventory(
item.ProductId, item.Quantity, available);
}
var order = Order.Create(command);
var paymentResult = await _payments.ChargeAsync(
order.Total, command.PaymentMethod);
if (!paymentResult.Success)
return new PaymentDeclined(paymentResult.FailureReason);
await _repository.AddAsync(order);
return order;
}
public async Task<OneOf<Order, OrderError>> GetOrderAsync(
Guid id, string userId)
{
var order = await _repository.GetByIdAsync(id);
if (order is null)
return new OrderNotFound(id);
if (!_auth.CanAccess(order, userId))
return new OrderForbidden(userId, "read");
return order;
}
}// Controller — matches on Result
public class OrderController : ControllerBase
{
private readonly OrderService _orderService;
public OrderController(OrderService orderService)
{
_orderService = orderService;
}
[HttpPost]
public async Task<IActionResult> CreateOrder(
[FromBody] CreateOrderCommand command)
{
var result = await _orderService.CreateOrderAsync(command);
return result.Match<IActionResult>(
order => CreatedAtAction(
nameof(GetOrder), new { id = order.Id }, order),
error => error switch
{
OrderValidationFailed v => UnprocessableEntity(v.Errors),
InsufficientInventory i => Conflict(new
{
error = "INSUFFICIENT_INVENTORY",
productId = i.ProductId,
requested = i.Requested,
available = i.Available
}),
PaymentDeclined p => StatusCode(402, new
{
error = "PAYMENT_DECLINED",
reason = p.Reason
}),
OrderForbidden => Forbid(),
_ => StatusCode(500) // The leftover catch-all
}
);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(Guid id)
{
var result = await _orderService.GetOrderAsync(
id, User.GetUserId());
return result.Match<IActionResult>(
order => Ok(order),
error => error switch
{
OrderNotFound => NotFound(),
OrderForbidden => Forbid(),
_ => StatusCode(500)
}
);
}
}// Controller — matches on Result
public class OrderController : ControllerBase
{
private readonly OrderService _orderService;
public OrderController(OrderService orderService)
{
_orderService = orderService;
}
[HttpPost]
public async Task<IActionResult> CreateOrder(
[FromBody] CreateOrderCommand command)
{
var result = await _orderService.CreateOrderAsync(command);
return result.Match<IActionResult>(
order => CreatedAtAction(
nameof(GetOrder), new { id = order.Id }, order),
error => error switch
{
OrderValidationFailed v => UnprocessableEntity(v.Errors),
InsufficientInventory i => Conflict(new
{
error = "INSUFFICIENT_INVENTORY",
productId = i.ProductId,
requested = i.Requested,
available = i.Available
}),
PaymentDeclined p => StatusCode(402, new
{
error = "PAYMENT_DECLINED",
reason = p.Reason
}),
OrderForbidden => Forbid(),
_ => StatusCode(500) // The leftover catch-all
}
);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(Guid id)
{
var result = await _orderService.GetOrderAsync(
id, User.GetUserId());
return result.Match<IActionResult>(
order => Ok(order),
error => error switch
{
OrderNotFound => NotFound(),
OrderForbidden => Forbid(),
_ => StatusCode(500)
}
);
}
}This is genuinely better. The return type OneOf<Order, OrderError> tells the caller that this method can fail. The Match method encourages handling both cases. The error types are explicit, documented in the type system, and carry structured data.
The Convention
Now comes the convention layer:
"All application service methods must return
Result<T>(orOneOf<TSuccess, TError>). Never throw exceptions from services. Always match the result in the controller. Always handle every error case."
This convention goes in the wiki. Every team writes a variation of this page.
THE DOCUMENTATION
# Error Handling Guide
## Principles
1. Application services return `OneOf<TSuccess, TError>`, never throw
2. Domain exceptions (NotFoundException etc.) are banned — use error types
3. Infrastructure exceptions (DbException, HttpRequestException) are caught
at the service boundary and wrapped in an error type
4. Controllers must Match() on every Result — no .Value access
5. Every error type must have a corresponding HTTP status code mapping
## Error Type Naming
- Suffix all error types with the entity name: OrderNotFound, not NotFound
- Group errors in a single file per domain: OrderErrors.cs, PaymentErrors.cs
- Use record types for errors (immutable, value equality)
## Controller Matching Rules
- Match must be exhaustive — handle every error subtype
- The default `_` case must log and return 500, never swallow silently
- Use pattern matching in the error branch, not if/else chains
## When to Use Exceptions vs Results
- Results: all business logic, all domain operations, all expected failures
- Exceptions: programmer errors (ArgumentNullException),
infrastructure failures that cannot be recovered
- Never: catch Exception in application services
## Code Review Checklist
- [ ] Service method returns OneOf, not bare type
- [ ] All Result branches handled in controller
- [ ] No .Value access without prior Match
- [ ] Error types carry structured data (not just messages)
- [ ] Default case logs before returning 500
- [ ] New error types documented in OrderErrors.cs# Error Handling Guide
## Principles
1. Application services return `OneOf<TSuccess, TError>`, never throw
2. Domain exceptions (NotFoundException etc.) are banned — use error types
3. Infrastructure exceptions (DbException, HttpRequestException) are caught
at the service boundary and wrapped in an error type
4. Controllers must Match() on every Result — no .Value access
5. Every error type must have a corresponding HTTP status code mapping
## Error Type Naming
- Suffix all error types with the entity name: OrderNotFound, not NotFound
- Group errors in a single file per domain: OrderErrors.cs, PaymentErrors.cs
- Use record types for errors (immutable, value equality)
## Controller Matching Rules
- Match must be exhaustive — handle every error subtype
- The default `_` case must log and return 500, never swallow silently
- Use pattern matching in the error branch, not if/else chains
## When to Use Exceptions vs Results
- Results: all business logic, all domain operations, all expected failures
- Exceptions: programmer errors (ArgumentNullException),
infrastructure failures that cannot be recovered
- Never: catch Exception in application services
## Code Review Checklist
- [ ] Service method returns OneOf, not bare type
- [ ] All Result branches handled in controller
- [ ] No .Value access without prior Match
- [ ] Error types carry structured data (not just messages)
- [ ] Default case logs before returning 500
- [ ] New error types documented in OrderErrors.csThat is 35 lines of documentation that must be kept current, read by every new developer, and checked in every code review.
THE ENFORCEMENT CODE
// Test: All application service public methods return OneOf<,>
[Fact]
public void All_Service_Methods_Should_Return_Result()
{
var serviceTypes = typeof(OrderService).Assembly
.GetTypes()
.Where(t => t.Name.EndsWith("Service")
&& !t.IsInterface && !t.IsAbstract);
foreach (var service in serviceTypes)
{
var publicMethods = service.GetMethods(
BindingFlags.Public | BindingFlags.Instance
| BindingFlags.DeclaredOnly);
foreach (var method in publicMethods)
{
var returnType = method.ReturnType;
// Unwrap Task<T>
if (returnType.IsGenericType
&& returnType.GetGenericTypeDefinition() == typeof(Task<>))
{
returnType = returnType.GetGenericArguments()[0];
}
// Check it's OneOf<,>
returnType.IsGenericType.Should().BeTrue(
$"{service.Name}.{method.Name} should return " +
$"OneOf<TSuccess, TError>, but returns {returnType.Name}");
var genericDef = returnType.GetGenericTypeDefinition();
genericDef.Name.Should().StartWith("OneOf",
$"{service.Name}.{method.Name} must return a Result type");
}
}
}
// Test: No code uses .Value on OneOf (should use .Match instead)
[Fact]
public void No_Direct_Value_Access_On_Result()
{
// This test scans source files — fragile, slow, and incomplete
var sourceFiles = Directory.GetFiles(
"../../../..", "*.cs", SearchOption.AllDirectories)
.Where(f => !f.Contains("obj") && !f.Contains("bin"));
var violations = new List<string>();
foreach (var file in sourceFiles)
{
var content = File.ReadAllText(file);
if (content.Contains(".AsT0") || content.Contains(".AsT1"))
{
violations.Add(file);
}
}
violations.Should().BeEmpty(
"Direct .AsT0/.AsT1 access bypasses exhaustive matching. " +
"Use .Match() instead.");
}// Test: All application service public methods return OneOf<,>
[Fact]
public void All_Service_Methods_Should_Return_Result()
{
var serviceTypes = typeof(OrderService).Assembly
.GetTypes()
.Where(t => t.Name.EndsWith("Service")
&& !t.IsInterface && !t.IsAbstract);
foreach (var service in serviceTypes)
{
var publicMethods = service.GetMethods(
BindingFlags.Public | BindingFlags.Instance
| BindingFlags.DeclaredOnly);
foreach (var method in publicMethods)
{
var returnType = method.ReturnType;
// Unwrap Task<T>
if (returnType.IsGenericType
&& returnType.GetGenericTypeDefinition() == typeof(Task<>))
{
returnType = returnType.GetGenericArguments()[0];
}
// Check it's OneOf<,>
returnType.IsGenericType.Should().BeTrue(
$"{service.Name}.{method.Name} should return " +
$"OneOf<TSuccess, TError>, but returns {returnType.Name}");
var genericDef = returnType.GetGenericTypeDefinition();
genericDef.Name.Should().StartWith("OneOf",
$"{service.Name}.{method.Name} must return a Result type");
}
}
}
// Test: No code uses .Value on OneOf (should use .Match instead)
[Fact]
public void No_Direct_Value_Access_On_Result()
{
// This test scans source files — fragile, slow, and incomplete
var sourceFiles = Directory.GetFiles(
"../../../..", "*.cs", SearchOption.AllDirectories)
.Where(f => !f.Contains("obj") && !f.Contains("bin"));
var violations = new List<string>();
foreach (var file in sourceFiles)
{
var content = File.ReadAllText(file);
if (content.Contains(".AsT0") || content.Contains(".AsT1"))
{
violations.Add(file);
}
}
violations.Should().BeEmpty(
"Direct .AsT0/.AsT1 access bypasses exhaustive matching. " +
"Use .Match() instead.");
}That is 45 lines of enforcement code that runs at test time, not compile time. And it still does not catch the most common failure.
What Slips Through
The convention says "always handle every error case." The enforcement test checks that service methods return OneOf<,>. But nothing checks what happens after the Result is returned. The developer can:
Ignore the result entirely:
await _orderService.CreateOrderAsync(command);— the Result is discarded. No warning. No error.Access .Value directly:
var order = result.AsT0;— throws if the result is an error. This is the same as.Valueon a nullable without checking. It compiles. It crashes at runtime.Match incompletely: The
_ => StatusCode(500)default case handles everything the developer forgot to match explicitly. The code compiles, runs, and silently returns 500 for error types that deserved specific handling.Add a new error type and forget to update matches:
OrderSuspendedis added toOrderError. No existingMatch()call breaks. The default case absorbs it. The user gets a 500 for a suspended order that should have been a 423.
The convention requires exhaustive matching. Nothing enforces exhaustive matching. The gap between the convention and the compiler is where production incidents live.
Convention overhead: 35 lines documentation + 45 lines enforcement = 80 lines — and the most important rule (exhaustive handling) is not enforced at all.
Era 4: Contention -- [MustHandle] and Generated Exhaustive Match
The Contention approach: make the type system enforce what the convention only describes.
The Attribute
// The developer writes this — the single source of truth
[MustHandle]
public abstract record OrderError;
public record OrderNotFound(Guid OrderId) : OrderError;
public record OrderValidationFailed(
IDictionary<string, string[]> Errors) : OrderError;
public record InsufficientInventory(
string ProductId, int Requested, int Available) : OrderError;
public record PaymentDeclined(string Reason) : OrderError;
public record OrderForbidden(string UserId, string Action) : OrderError;// The developer writes this — the single source of truth
[MustHandle]
public abstract record OrderError;
public record OrderNotFound(Guid OrderId) : OrderError;
public record OrderValidationFailed(
IDictionary<string, string[]> Errors) : OrderError;
public record InsufficientInventory(
string ProductId, int Requested, int Available) : OrderError;
public record PaymentDeclined(string Reason) : OrderError;
public record OrderForbidden(string UserId, string Action) : OrderError;[MustHandle] is one attribute. It tells the Source Generator: "This is a discriminated union. Generate exhaustive matching. Enforce it at compile time."
What the Source Generator Produces
// ── Generated: OrderErrorExtensions.g.cs ──
[GeneratedCode("Cmf.ErrorHandling.Generators", "1.0.0")]
public static class OrderErrorExtensions
{
/// <summary>
/// Exhaustive match on all <see cref="OrderError"/> subtypes.
/// Every case must be handled — adding a new subtype will cause
/// a compile error at every call site until it is matched.
/// </summary>
public static TResult Match<TResult>(
this OrderError error,
Func<OrderNotFound, TResult> onNotFound,
Func<OrderValidationFailed, TResult> onValidationFailed,
Func<InsufficientInventory, TResult> onInsufficientInventory,
Func<PaymentDeclined, TResult> onPaymentDeclined,
Func<OrderForbidden, TResult> onForbidden)
{
return error switch
{
OrderNotFound e => onNotFound(e),
OrderValidationFailed e => onValidationFailed(e),
InsufficientInventory e => onInsufficientInventory(e),
PaymentDeclined e => onPaymentDeclined(e),
OrderForbidden e => onForbidden(e),
_ => throw new InvalidOperationException(
$"Unhandled {nameof(OrderError)} subtype: " +
$"{error.GetType().Name}. " +
"Regenerate to update the Match method.")
};
}
/// <summary>
/// Exhaustive match with no return value (side effects only).
/// </summary>
public static void Switch(
this OrderError error,
Action<OrderNotFound> onNotFound,
Action<OrderValidationFailed> onValidationFailed,
Action<InsufficientInventory> onInsufficientInventory,
Action<PaymentDeclined> onPaymentDeclined,
Action<OrderForbidden> onForbidden)
{
switch (error)
{
case OrderNotFound e: onNotFound(e); break;
case OrderValidationFailed e: onValidationFailed(e); break;
case InsufficientInventory e: onInsufficientInventory(e); break;
case PaymentDeclined e: onPaymentDeclined(e); break;
case OrderForbidden e: onForbidden(e); break;
default: throw new InvalidOperationException(
$"Unhandled {nameof(OrderError)} subtype: " +
$"{error.GetType().Name}");
}
}
/// <summary>
/// Maps <see cref="OrderError"/> to a common result envelope,
/// with typed status codes and structured error data.
/// </summary>
public static ErrorEnvelope ToEnvelope(this OrderError error)
{
return error switch
{
OrderNotFound e => new ErrorEnvelope(
"ORDER_NOT_FOUND", 404,
$"Order {e.OrderId} was not found"),
OrderValidationFailed e => new ErrorEnvelope(
"VALIDATION_FAILED", 422,
"One or more validation errors occurred",
e.Errors),
InsufficientInventory e => new ErrorEnvelope(
"INSUFFICIENT_INVENTORY", 409,
$"Product {e.ProductId}: requested {e.Requested}, " +
$"available {e.Available}"),
PaymentDeclined e => new ErrorEnvelope(
"PAYMENT_DECLINED", 402, e.Reason),
OrderForbidden e => new ErrorEnvelope(
"FORBIDDEN", 403,
$"User {e.UserId} cannot {e.Action} this order"),
_ => new ErrorEnvelope(
"INTERNAL_ERROR", 500, "An unexpected error occurred")
};
}
}// ── Generated: OrderErrorExtensions.g.cs ──
[GeneratedCode("Cmf.ErrorHandling.Generators", "1.0.0")]
public static class OrderErrorExtensions
{
/// <summary>
/// Exhaustive match on all <see cref="OrderError"/> subtypes.
/// Every case must be handled — adding a new subtype will cause
/// a compile error at every call site until it is matched.
/// </summary>
public static TResult Match<TResult>(
this OrderError error,
Func<OrderNotFound, TResult> onNotFound,
Func<OrderValidationFailed, TResult> onValidationFailed,
Func<InsufficientInventory, TResult> onInsufficientInventory,
Func<PaymentDeclined, TResult> onPaymentDeclined,
Func<OrderForbidden, TResult> onForbidden)
{
return error switch
{
OrderNotFound e => onNotFound(e),
OrderValidationFailed e => onValidationFailed(e),
InsufficientInventory e => onInsufficientInventory(e),
PaymentDeclined e => onPaymentDeclined(e),
OrderForbidden e => onForbidden(e),
_ => throw new InvalidOperationException(
$"Unhandled {nameof(OrderError)} subtype: " +
$"{error.GetType().Name}. " +
"Regenerate to update the Match method.")
};
}
/// <summary>
/// Exhaustive match with no return value (side effects only).
/// </summary>
public static void Switch(
this OrderError error,
Action<OrderNotFound> onNotFound,
Action<OrderValidationFailed> onValidationFailed,
Action<InsufficientInventory> onInsufficientInventory,
Action<PaymentDeclined> onPaymentDeclined,
Action<OrderForbidden> onForbidden)
{
switch (error)
{
case OrderNotFound e: onNotFound(e); break;
case OrderValidationFailed e: onValidationFailed(e); break;
case InsufficientInventory e: onInsufficientInventory(e); break;
case PaymentDeclined e: onPaymentDeclined(e); break;
case OrderForbidden e: onForbidden(e); break;
default: throw new InvalidOperationException(
$"Unhandled {nameof(OrderError)} subtype: " +
$"{error.GetType().Name}");
}
}
/// <summary>
/// Maps <see cref="OrderError"/> to a common result envelope,
/// with typed status codes and structured error data.
/// </summary>
public static ErrorEnvelope ToEnvelope(this OrderError error)
{
return error switch
{
OrderNotFound e => new ErrorEnvelope(
"ORDER_NOT_FOUND", 404,
$"Order {e.OrderId} was not found"),
OrderValidationFailed e => new ErrorEnvelope(
"VALIDATION_FAILED", 422,
"One or more validation errors occurred",
e.Errors),
InsufficientInventory e => new ErrorEnvelope(
"INSUFFICIENT_INVENTORY", 409,
$"Product {e.ProductId}: requested {e.Requested}, " +
$"available {e.Available}"),
PaymentDeclined e => new ErrorEnvelope(
"PAYMENT_DECLINED", 402, e.Reason),
OrderForbidden e => new ErrorEnvelope(
"FORBIDDEN", 403,
$"User {e.UserId} cannot {e.Action} this order"),
_ => new ErrorEnvelope(
"INTERNAL_ERROR", 500, "An unexpected error occurred")
};
}
}// ── Generated: Result{T,TError}.g.cs ──
[GeneratedCode("Cmf.ErrorHandling.Generators", "1.0.0")]
public readonly struct Result<TSuccess, TError>
where TError : class
{
private readonly TSuccess? _value;
private readonly TError? _error;
private readonly bool _isSuccess;
private Result(TSuccess value)
{
_value = value;
_error = default;
_isSuccess = true;
}
private Result(TError error)
{
_value = default;
_error = error;
_isSuccess = false;
}
// No .Value property. No .Error property.
// The ONLY way to extract the value is Match.
public TResult Match<TResult>(
Func<TSuccess, TResult> onSuccess,
Func<TError, TResult> onError)
{
return _isSuccess
? onSuccess(_value!)
: onError(_error!);
}
public void Switch(
Action<TSuccess> onSuccess,
Action<TError> onError)
{
if (_isSuccess)
onSuccess(_value!);
else
onError(_error!);
}
public static implicit operator Result<TSuccess, TError>(
TSuccess value) => new(value);
public static implicit operator Result<TSuccess, TError>(
TError error) => new(error);
}// ── Generated: Result{T,TError}.g.cs ──
[GeneratedCode("Cmf.ErrorHandling.Generators", "1.0.0")]
public readonly struct Result<TSuccess, TError>
where TError : class
{
private readonly TSuccess? _value;
private readonly TError? _error;
private readonly bool _isSuccess;
private Result(TSuccess value)
{
_value = value;
_error = default;
_isSuccess = true;
}
private Result(TError error)
{
_value = default;
_error = error;
_isSuccess = false;
}
// No .Value property. No .Error property.
// The ONLY way to extract the value is Match.
public TResult Match<TResult>(
Func<TSuccess, TResult> onSuccess,
Func<TError, TResult> onError)
{
return _isSuccess
? onSuccess(_value!)
: onError(_error!);
}
public void Switch(
Action<TSuccess> onSuccess,
Action<TError> onError)
{
if (_isSuccess)
onSuccess(_value!);
else
onError(_error!);
}
public static implicit operator Result<TSuccess, TError>(
TSuccess value) => new(value);
public static implicit operator Result<TSuccess, TError>(
TError error) => new(error);
}The Analyzers Enforce Everything
The SG generates the Match method. The analyzers ensure it is used correctly — at compile time, in the IDE, with red squiggles.
// RES001: Result<T> assigned but not matched
// Severity: Error
// Trigger: Variable of type Result<,> is assigned but never
// passed to .Match() or .Switch()
error RES001: Result 'createResult' of type Result<Order, OrderError>
is assigned but never matched.
All Result values must be handled via .Match() or .Switch().
Discarding a Result silently ignores potential errors.
(OrderController.cs, line 23)// RES001: Result<T> assigned but not matched
// Severity: Error
// Trigger: Variable of type Result<,> is assigned but never
// passed to .Match() or .Switch()
error RES001: Result 'createResult' of type Result<Order, OrderError>
is assigned but never matched.
All Result values must be handled via .Match() or .Switch().
Discarding a Result silently ignores potential errors.
(OrderController.cs, line 23)// RES002: Match() missing a case
// Severity: Error
// Trigger: The generated Match() method requires N parameters
// (one per union subtype). Caller provides fewer.
// This is a standard compile error — the method signature
// demands all cases.
error CS7036: There is no argument given that corresponds to the
required parameter 'onPaymentDeclined' of
'OrderErrorExtensions.Match<TResult>(OrderError,
Func<OrderNotFound, TResult>,
Func<OrderValidationFailed, TResult>,
Func<InsufficientInventory, TResult>,
Func<PaymentDeclined, TResult>,
Func<OrderForbidden, TResult>)'
(OrderController.cs, line 34)// RES002: Match() missing a case
// Severity: Error
// Trigger: The generated Match() method requires N parameters
// (one per union subtype). Caller provides fewer.
// This is a standard compile error — the method signature
// demands all cases.
error CS7036: There is no argument given that corresponds to the
required parameter 'onPaymentDeclined' of
'OrderErrorExtensions.Match<TResult>(OrderError,
Func<OrderNotFound, TResult>,
Func<OrderValidationFailed, TResult>,
Func<InsufficientInventory, TResult>,
Func<PaymentDeclined, TResult>,
Func<OrderForbidden, TResult>)'
(OrderController.cs, line 34)This is the critical insight: RES002 is not even a custom analyzer. It is a standard C# compiler error. The generated Match() method has one parameter per union subtype. If the developer does not provide a handler for PaymentDeclined, the code does not compile. This is exhaustiveness through method signatures, not through convention.
When someone adds OrderSuspended as a new OrderError subtype, the SG regenerates Match() with six parameters instead of five. Every call site that previously compiled now fails with CS7036 until the developer adds the onSuspended handler. The compiler finds every place that needs updating. The developer does not need to grep, does not need to remember, does not need to check the wiki.
// RES003: Direct value extraction on Result
// Severity: Error
// Trigger: Code accesses internal state of Result<,> without Match
error RES003: Direct access to Result internals is not permitted.
Use .Match() to handle both success and error cases.
Direct extraction bypasses error handling and will
throw at runtime if the Result contains an error.
(OrderProcessor.cs, line 45)// RES003: Direct value extraction on Result
// Severity: Error
// Trigger: Code accesses internal state of Result<,> without Match
error RES003: Direct access to Result internals is not permitted.
Use .Match() to handle both success and error cases.
Direct extraction bypasses error handling and will
throw at runtime if the Result contains an error.
(OrderProcessor.cs, line 45)// RES004: Method returns Result but caller ignores return value
// Severity: Warning
// Trigger: A method returning Result<,> is called but the return
// value is not assigned to a variable.
warning RES004: Return value of 'CreateOrderAsync' (Result<Order, OrderError>)
is not captured. The operation may have failed and the
error is being silently discarded.
Assign the result and call .Match() to handle errors.
(OrderProcessor.cs, line 12)// RES004: Method returns Result but caller ignores return value
// Severity: Warning
// Trigger: A method returning Result<,> is called but the return
// value is not assigned to a variable.
warning RES004: Return value of 'CreateOrderAsync' (Result<Order, OrderError>)
is not captured. The operation may have failed and the
error is being silently discarded.
Assign the result and call .Match() to handle errors.
(OrderProcessor.cs, line 12)Using It in the Controller
// Era 4: Contention — the controller
public class OrderController : ControllerBase
{
private readonly OrderService _orderService;
public OrderController(OrderService orderService)
{
_orderService = orderService;
}
[HttpPost]
public async Task<IActionResult> CreateOrder(
[FromBody] CreateOrderCommand command)
{
var result = await _orderService.CreateOrderAsync(command);
return result.Match(
order => CreatedAtAction(
nameof(GetOrder), new { id = order.Id }, order),
error => error.Match(
onNotFound: e => NotFound(e.ToEnvelope()) as IActionResult,
onValidationFailed: e => UnprocessableEntity(e.ToEnvelope()),
onInsufficientInventory: e =>
Conflict(e.ToEnvelope()),
onPaymentDeclined: e =>
StatusCode(402, e.ToEnvelope()),
onForbidden: e => StatusCode(403, e.ToEnvelope())
)
);
}
}// Era 4: Contention — the controller
public class OrderController : ControllerBase
{
private readonly OrderService _orderService;
public OrderController(OrderService orderService)
{
_orderService = orderService;
}
[HttpPost]
public async Task<IActionResult> CreateOrder(
[FromBody] CreateOrderCommand command)
{
var result = await _orderService.CreateOrderAsync(command);
return result.Match(
order => CreatedAtAction(
nameof(GetOrder), new { id = order.Id }, order),
error => error.Match(
onNotFound: e => NotFound(e.ToEnvelope()) as IActionResult,
onValidationFailed: e => UnprocessableEntity(e.ToEnvelope()),
onInsufficientInventory: e =>
Conflict(e.ToEnvelope()),
onPaymentDeclined: e =>
StatusCode(402, e.ToEnvelope()),
onForbidden: e => StatusCode(403, e.ToEnvelope())
)
);
}
}No _ => StatusCode(500). No default case. Every error type has a specific handler. If OrderSuspended is added tomorrow, this controller will not compile until a handler for onSuspended is added. The compiler is the enforcement. The wiki is unnecessary.
Adding a New Error Type
Watch what happens when the domain evolves:
// A developer adds a new error type
[MustHandle]
public abstract record OrderError;
public record OrderNotFound(Guid OrderId) : OrderError;
public record OrderValidationFailed(
IDictionary<string, string[]> Errors) : OrderError;
public record InsufficientInventory(
string ProductId, int Requested, int Available) : OrderError;
public record PaymentDeclined(string Reason) : OrderError;
public record OrderForbidden(string UserId, string Action) : OrderError;
public record OrderSuspended(Guid OrderId, string Reason) : OrderError; // NEW// A developer adds a new error type
[MustHandle]
public abstract record OrderError;
public record OrderNotFound(Guid OrderId) : OrderError;
public record OrderValidationFailed(
IDictionary<string, string[]> Errors) : OrderError;
public record InsufficientInventory(
string ProductId, int Requested, int Available) : OrderError;
public record PaymentDeclined(string Reason) : OrderError;
public record OrderForbidden(string UserId, string Action) : OrderError;
public record OrderSuspended(Guid OrderId, string Reason) : OrderError; // NEWThe SG regenerates Match() with a new parameter: Func<OrderSuspended, TResult> onSuspended. Build. Every existing Match() call now has a compile error:
error CS7036: There is no argument given that corresponds to the
required parameter 'onSuspended' of
'OrderErrorExtensions.Match<TResult>(...)'
(OrderController.cs, line 34)
(OrderProcessor.cs, line 78)
(OrderEventHandler.cs, line 22)
(RetryService.cs, line 91)error CS7036: There is no argument given that corresponds to the
required parameter 'onSuspended' of
'OrderErrorExtensions.Match<TResult>(...)'
(OrderController.cs, line 34)
(OrderProcessor.cs, line 78)
(OrderEventHandler.cs, line 22)
(RetryService.cs, line 91)Four call sites. All found by the compiler. Zero found by convention tests (because convention tests check that services return Results, not that matches are exhaustive). Zero found by code review (because code review is manual and fallible).
What Disappears
No wiki/error-handling-guide.md — [MustHandle] IS the strategy
No enforcement tests — RES001-RES004 at compile time
No default _ => 500 catch-all — Match requires all cases
No .Value/.AsT0 escape hatches — RES003 blocks direct access
No manual error-to-HTTP mapping — SG generates ToEnvelope()
No "did you handle the new error type" — CS7036 finds every call site
────────────────────────────────────────
Convention overhead: 0 lines
Wiki pages: 0
Enforcement tests: 0No wiki/error-handling-guide.md — [MustHandle] IS the strategy
No enforcement tests — RES001-RES004 at compile time
No default _ => 500 catch-all — Match requires all cases
No .Value/.AsT0 escape hatches — RES003 blocks direct access
No manual error-to-HTTP mapping — SG generates ToEnvelope()
No "did you handle the new error type" — CS7036 finds every call site
────────────────────────────────────────
Convention overhead: 0 lines
Wiki pages: 0
Enforcement tests: 0The Convention Tax -- Measured
| Artifact | Convention (Era 3) | Contention (Era 4) |
|---|---|---|
| Error handling strategy | Wiki page (35 lines) | [MustHandle] attribute |
| Enforcement of "return Result" | Test (20 lines) | RES004 analyzer — compile time |
| Enforcement of "match exhaustively" | Code review (manual, fallible) | CS7036 — compile error |
| Enforcement of "no .Value access" | Source-scanning test (25 lines) | RES003 analyzer — compile time |
| Match method per error hierarchy | Manual switch expression | SG-generated exhaustive Match() |
| Error-to-HTTP mapping | Manual per controller | SG-generated ToEnvelope() |
| Adding new error type | Grep + update all match sites (hope) | Build fails at every unmatched site |
| Default catch-all | _ => 500 absorbs unknown errors |
No default — every case is explicit |
| Total convention overhead | 80 lines + perpetual code review burden | 0 lines |
| Catches missing handler | Never (default case absorbs) | Compile time (CS7036) |
| Catches discarded Result | Never | Compile time (RES001) |
| New error type workflow | Add type, grep for matches, update, hope | Add type, build, fix every red squiggle |
The developer's workflow for adding a new error case drops from "add the type, search the codebase for every place that matches this error hierarchy, update each match, update the wiki, hope you found them all" to "add the type, build, fix the compile errors." The compiler is faster, more thorough, and more reliable than any human process.
Before and After: Error Propagation by Era
Each era makes error paths more explicit. But only Contention makes them enforceable. The _ => StatusCode(500) default case in Era 3 is a trapdoor — it absorbs every error type you forgot to handle. The generated Match() in Era 4 has no trapdoor. Every case is a required parameter.
The Unhandled Error Surface
What slips through in each era — and when you discover it.
The critical difference: in Eras 1-3, unhandled errors are discovered in production — by users, by incident responders, by data corruption audits. In Era 4, unhandled errors are discovered by the compiler — before the code ships, before the PR merges, before the build artifact is created.
Generated Match: From Union Type to Exhaustive Switch
The full generation pipeline from [MustHandle] to exhaustive matching.
The entire right side of this diagram — verification — is free. The compiler already checks method signatures. By generating Match methods with one parameter per subtype, the SG turns the compiler's existing signature checking into exhaustiveness checking. No custom analyzer needed for the core guarantee. RES001-RES004 are supplementary — they catch misuse patterns. The exhaustiveness itself is just C#.
The Meta-Pattern
Error handling follows the same trajectory as every other domain in this series:
- Code: manual, inconsistent, scattered
- Configuration: structured but invisible to callers (exceptions hide behavior behind return types that lie)
- Convention: explicit return types but unenforceable exhaustiveness (Match exists, but so does the default case, and so does .Value)
- Contention: generated exhaustive matching where the compiler is the enforcer
The specific insight for error handling is that Convention's most important rule — exhaustive matching — is the one it cannot enforce. Convention can enforce that services return Result<T>. Convention can enforce that a .Match() call exists. Convention cannot enforce that the match handles every case without a default escape hatch — because that is a property of the method signature, and convention does not generate method signatures.
Contention generates the method signature. That is the entire difference. One generated method with N required parameters replaces a wiki page, two enforcement tests, and an infinite number of code review comments that say "did you handle the PaymentDeclined case?"
Cross-References
This pattern connects directly to two other posts:
Requirements as Code -- The feature compliance system uses Results in its validation chain. When a requirement has acceptance criteria, and those criteria map to validators, and those validators return
Result<ValidationPassed, ComplianceFailed>, the error handling chain is type-safe end to end.[MustHandle]ensures every compliance failure is explicitly addressed — not absorbed by a default case.Don't Put the Burden on Developers -- "Match all error cases" is a burden. "Remember to update matches when new error types are added" is a burden. "Check .AsT0 only after verifying the result is successful" is a burden. Contention eliminates all three burdens by making the wrong thing impossible to compile. The developer does not need discipline. The developer needs a method signature that demands completeness.
Closing
Error handling is the domain where Convention's gap is most dangerous. Missing validation is caught by the first user who submits bad data. Missing DDD conventions are caught by architecture tests (eventually). But missing error handling is caught by the production incident that happens at 2 AM when the payment gateway returns a new error code that nobody matched — and the default case returns 500, and the retry logic retries, and the customer is charged three times.
The [MustHandle] attribute and generated Match() method turn this category of production incident into a compile error. The convention "always handle every error case" costs 80 lines of documentation and enforcement that still cannot guarantee exhaustiveness. The attribute costs one word — and the compiler's own method signature checking does the rest.
Error handling is not optional behavior. It is the difference between software that works and software that works correctly. If that difference matters — and it does, every time, in every system — then it should be enforced by something that never forgets, never skips a code review, and never lets a default case absorb an error that deserved a specific response.
The compiler is that something. Convention asks it nicely. Contention makes it mandatory.
Next: Part X -- where cross-cutting concerns like logging, caching, and authorization follow the same pattern: documented conventions that drift, enforcement tests that lag, and Source Generators that make the compiler do what the wiki only describes.