The Result Pattern: Exceptions Are Not Your Error-Handling Strategy

The Result Pattern: Exceptions Are Not Your Error-Handling Strategy

08 Sep 2026

The Result Pattern: Exceptions Are Not Your Error-Handling Strategy

Throwing exceptions to handle expected failures is one of the most common anti-patterns in .NET. "What if the entity is not found?" Throw an InvalidOperationException. "What if the user does not have permission?" Throw an UnauthorizedAccessException. But exceptions are expensive to allocate, they break the normal control flow, and they make your error handling unpredictable.

The Result pattern fixes this. Every CQRS handler returns Result or Result<T> instead of throwing exceptions. This provides a consistent error-handling contract across all operations. Success or failure, it is all explicit.

Returning Success

// In a query handler:
return Result.Success(paginatedList);

// In a command handler:
return Result.Success();

Clean, explicit, no ambiguity. The caller knows exactly what happened.

Returning Failure

// On failure, use the appropriate factory method:
return Result.NotFound(request.Id.ToString());         // entity does not exist (non-generic)
return Result.NotFound<Payment?>(request.PaymentId);   // entity does not exist (generic)
return Result.Conflict(request.Id.ToString());         // entity state prevents the operation
return Result.ValidationError("message");              // business rule validation failed

Each failure type maps to a semantic meaning. NotFound means the entity does not exist. Conflict means the entity is in a state that prevents the operation. ValidationError means the input failed a business rule. The caller can map these to HTTP status codes with confidence.

Endpoint Consumption

var result = await commandBus.SendAsync(command, CancellationToken.None);
if (result.IsFailure) return Results.Problem(result.Error.Description);
return Results.NoContent();

The endpoint checks IsSuccess or IsFailure and maps to the appropriate HTTP response. No try/catch blocks. No exception filters for expected failures. Just a clean if/else.

Why It Works

  • Error handling is predictable and consistent. Every handler returns the same type. Every endpoint checks the same way. No surprises.
if (result.IsFailure) return Results.Problem(result.Error.Description);
  • No performance penalty for expected failures. A Result is just a struct with a value and an error. No stack trace allocation, no exception unwinding.
return Result.NotFound(request.Id.ToString());
  • Semantic error types map cleanly to HTTP status codes. NotFound becomes 404, Conflict becomes 409, ValidationError becomes 400.
return Result.Conflict(request.Id.ToString());
  • Callers are forced to handle the failure case. The Result return type does not hide failure behind a void or a thrown exception. It is right there in the method signature.
public async Task<Result> HandleAsync(UpsertBlogPostCommand request, CancellationToken cancellationToken)

Generic vs Non-Generic

The pattern distinguishes between operations that return data and those that do not:

  • Non-generic Result (commands with no return value) requires string in the error factory methods. Use request.Id.ToString().
  • Generic Result<T> (queries or commands with a return value) accepts object. Pass request.Id directly.
// Non-generic: commands
return Result.NotFound(request.Id.ToString());

// Generic: queries
return Result.NotFound<Payment?>(request.PaymentId);

Brand Website V3 implements the Result pattern across every CQRS handler. No exceptions for expected failures, no try/catch spaghetti, just clean error handling everywhere. Explore the full implementation at kiss-code.com.


Join the community

I continuously build, learn and experiment with innovative technology. Allow me to share what I learn, with you.

Newsletter

Allow me to share what I learn, with you.

Share

Support

An error has occurred. 🗙