Result Pattern over Exceptions
Exceptions are a control flow mechanism that C# uses for errors. They’re also invisible in method signatures. A method that returns ImportJob might throw ValidationException, NotFoundException, ConflictException, or nothing at all — you can’t tell from the signature. You find out at runtime, or by reading the implementation, or by looking at the tests if they exist.
For a pipeline where every handler can fail in several distinct ways, this felt like the wrong tradeoff.
What Result Does
Result<T> makes failure explicit in the return type:
public sealed class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public ApplicationError? Error { get; }
public static Result<T> Success(T value) => ...;
public static Result<T> Conflict(string code, string message) => ...;
public static Result<T> NotFound(string code, string message) => ...;
public static Result<T> Validation(string code, string message) => ...;
}
A handler that can return a job or fail with a conflict has signature:
Task<Result<ImportJobResponse>> Handle(CreateImportJobCommand command, CancellationToken ct);
The caller cannot ignore the failure case without explicitly choosing to. No invisible exception path.
ApplicationError carries an ErrorType enum — Validation | Conflict | NotFound | Unexpected — which the API layer maps directly to HTTP status codes. The mapping is centralized in one place, not scattered across exception filters.
return result.Error.Type switch
{
ErrorType.Validation => Results.UnprocessableEntity(problem),
ErrorType.Conflict => Results.Conflict(problem),
ErrorType.NotFound => Results.NotFound(problem),
_ => Results.Problem(problem)
};
What It Costs
The pattern is deliberately simple — no Bind, no Map, no monadic chaining. This means handlers have some repetitive structure:
var jobResult = await _repository.FindAsync(id, ct);
if (!jobResult.IsSuccess) return jobResult;
var validationResult = Validate(jobResult.Value);
if (!validationResult.IsSuccess) return validationResult;
More lines than a fluent chain. More readable to anyone who hasn’t used functional-style result types before. I chose readability here.
The other cost: ResultResult for business-logic failures, exceptions for infrastructure failures and programmer errors.
Where Exceptions Still Live
The Infrastructure layer throws. NpgsqlException on connection failure, TimeoutException on query timeout — these propagate as exceptions and are caught by the Worker’s retry orchestrator. The IExceptionClassifier service then classifies them as Transient or Permanent, which determines whether to retry or dead-letter.
This is the right split. Infrastructure failures are truly exceptional — unexpected, not part of the business logic contract. The application layer doesn’t need to handle them; the worker orchestration does. Mixing them into Result<T> would pollute every handler with infrastructure-awareness.
The Net Effect
Handlers read like a specification of what can go wrong:
// Returns: the created job, or Conflict if duplicate, or Validation if bad input
Task<Result<ImportJobResponse>> Handle(CreateImportJobCommand command, CancellationToken ct);
The API layer does one thing: map error types to HTTP codes. No exception filters, no catch (SpecificException) in controllers, no surprises from exception hierarchy mismatches.
Whether this is worth it depends on team context. For a solo portfolio project, the explicitness is its own reward — it forced me to think through every failure mode before writing the implementation.