Why I stopped using exceptions for control flow in my .NET 8 APIs
For a long time, my .NET APIs looked like this:
public async Task<Product> GetByIdAsync(string id)
{
var product = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync();
if (product is null)
throw new KeyNotFoundException($"Product with id '{id}' was not found.");
return product;
}
And in the endpoint:
app.MapGet("/api/products/{id}", async (string id, IProductRepository repo) =>
{
try
{
var product = await repo.GetByIdAsync(id);
return Results.Ok(product);
}
catch (KeyNotFoundException ex)
{
return Results.NotFound(ex.Message);
}
catch (Exception ex)
{
return Results.Problem(ex.Message);
}
});
It worked. But every endpoint had a try/catch. Every service method threw a different exception type. The caller had to know which exceptions to catch. Business logic and error routing were tangled together.
Then I came across the ErrorOr library by Amantinband, and it changed how I think about error handling entirely.
The problem with exceptions for control flow
Exceptions in .NET are designed for exceptional situations — things that should not happen and cannot be recovered from gracefully. Using them to signal "product not found" or "name already exists" is semantically wrong and has real costs:
- Performance — throwing and catching exceptions is significantly more expensive than returning a value. The CLR unwinds the call stack, captures a stack trace, and allocates an exception object every time.
- Hidden contracts — nothing in the method signature tells the caller what can go wrong. You find out at runtime, not compile time.
- Scattered try/catch — every caller has to know which exceptions to catch, and if they forget one, it bubbles up as an unhandled 500.
- Hard to test — asserting on thrown exceptions is more awkward than asserting on return values.
The alternative is to make errors part of the return type. This is not a new idea — it is how languages like Rust (Result<T, E>) and Go (multiple return values) handle it. In .NET, the ErrorOr library brings the same pattern with a clean API.
What ErrorOr looks like
ErrorOr<T> is a discriminated union that holds either a success value or one or more errors. Every operation either succeeds or fails — and the caller is forced to handle both cases.
Here is what my domain error definitions look like in FenixKit:
public static partial class ProductErrors
{
public static Error NotFound(string id) =>
Error.NotFound(
code: "Product.NotFound",
description: $"Product with id '{id}' was not found.");
public static Error NameConflict(string name) =>
Error.Conflict(
code: "Product.NameConflict",
description: $"A product with name '{name}' already exists.");
public static readonly Error InvalidPrice =
Error.Validation(
code: "Product.InvalidPrice",
description: "Price must be greater than zero.");
public static readonly Error InvalidCategory =
Error.Validation(
code: "Product.InvalidCategory",
description: "Category cannot be empty.");
}
Each error has a type (NotFound, Conflict, Validation), a machine-readable code, and a human-readable description. No exception classes, no inheritance hierarchy, no throwing.
How it flows through the stack
The repository
Validation runs before the database is touched. If anything fails, we return an error and the operation stops:
public override async Task<ErrorOr<Success>> OnValidateCreateAsync(
ProductCreateRequest request, CancellationToken ct = default)
{
List<Error> errors = [];
if (request.Price <= 0)
errors.Add(ProductErrors.InvalidPrice);
if (string.IsNullOrWhiteSpace(request.Category))
errors.Add(ProductErrors.InvalidCategory);
// Return all validation errors at once — no need to make the
// client fix one problem, resubmit, and discover the next one.
if (errors.Count > 0)
return errors;
// Only hit the database for uniqueness check if format validation passed.
// This avoids a round-trip when the request is obviously invalid.
var exists = await ExistsByNameAsync(request.Name, ct);
if (exists.IsError)
return exists.Errors;
return exists.Value
? ProductErrors.NameConflict(request.Name)
: Result.Success;
}
The hook chain
Every hook in BaseRepository returns ErrorOr<T>, so a failure at any stage aborts the operation cleanly before the database is touched:
public virtual async Task<ErrorOr<TDetailResponse>> CreateAsync(
TCreateRequest request, CancellationToken ct = default)
{
// 1. Validate — aborts if invalid
var validation = await OnValidateCreateAsync(request, ct);
if (validation.IsError) return validation.Errors;
// 2. Map and enrich — aborts if enrichment fails
var entity = request.ToDBEntity();
var enriched = await OnMapCreateAsync(request, entity, ct);
if (enriched.IsError) return enriched.Errors;
// 3. Persist — aborts if the DB call fails
var created = await _Repository.CreateAsync(enriched.Value, ct);
if (created.IsError) return created.Errors;
// 4. Project to response — aborts if projection fails
var detail = await OnMapToDetailAsync(created.Value, ct);
if (detail.IsError) return detail.Errors;
return detail.Value;
}
No try/catch anywhere. Each step either produces a value or returns errors — and errors short-circuit the chain.
The endpoint
At the endpoint layer, Match forces you to handle both cases. There is no way to accidentally ignore the error path:
private static async Task<IResult> Create(
ProductCreateRequest dto,
[FromServices] IProductRepository repo,
CancellationToken ct)
{
var result = await repo.CreateAsync(dto, ct);
return result.Match(
created => Results.Created($"/api/products/{created.Id}", created),
errors => errors.ToResponse());
}
errors.ToResponse() maps each ErrorOr error type to the correct HTTP status:
public static IResult ToResponse(this List<Error> errors)
{
// All validation errors → 422 with all field errors grouped
if (errors.All(e => e.Type == ErrorType.Validation))
{
return Results.ValidationProblem(
errors.ToDictionary(
e => e.Code,
e => new[] { e.Description }), statusCode: 422);
}
var first = errors.First();
return first.Type switch
{
ErrorType.NotFound => Results.NotFound(ToProblem(first, 404)),
ErrorType.Conflict => Results.Conflict(ToProblem(first, 409)),
ErrorType.Unauthorized => Results.Unauthorized(),
ErrorType.Forbidden => Results.Forbid(),
_ => Results.Problem(ToProblem(first, 500).Detail)
};
}
The mapping is defined once, used everywhere. No endpoint needs to know about HTTP status codes — it just calls ToResponse().
What the response looks like
A validation failure returns a proper RFC 7807 response:
{
"type": "https://tools.ietf.org/html/rfc7807",
"status": 422,
"errors": {
"Product.InvalidPrice": ["Price must be greater than zero."],
"Product.InvalidCategory": ["Category cannot be empty."]
}
}
All errors are returned at once — not one at a time. The client fixes everything in one round-trip.
A not-found error returns:
{
"status": 404,
"title": "Product.NotFound",
"detail": "Product with id '6638f1a2b3c4d5e6f7a8b9c0' was not found."
}
Machine-readable code, human-readable description, correct HTTP status. No exceptions needed.
The global exception handler as a safety net
ErrorOr handles expected errors — things your domain knows can go wrong. But bugs happen. For everything truly unexpected, a global exception handler catches it and converts it to a ProblemDetails response before it reaches the client:
public async ValueTask<bool> TryHandleAsync(
HttpContext context, Exception exception, CancellationToken ct)
{
var (statusCode, title) = exception switch
{
KeyNotFoundException => (404, "Resource not found"),
UnauthorizedAccessException => (401, "Unauthorized"),
ArgumentException => (400, "Bad request"),
InvalidOperationException => (409, "Conflict"),
_ => (500, "Internal server error")
};
var problem = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = exception.Message,
Instance = context.Request.Path
};
// TraceId for correlation — useful when the client reports a bug
problem.Extensions["traceId"] = context.TraceIdentifier;
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problem, ct);
return true;
}
No stack traces leak to the client. No 500 with a raw exception message. The traceId lets you correlate a client report with your logs.
Summary
Switching to ErrorOr changed three things in my APIs:
Errors are explicit. The return type tells you what can go wrong. You do not discover error cases at runtime.
No scattered try/catch. The only try/catch in the whole codebase is the global exception handler — which exists for genuine bugs, not business logic.
HTTP mapping is centralised. ToResponse() is defined once. Every endpoint uses it. Adding a new error type means updating one switch, not every endpoint that could encounter it.
The pattern does require a small mindset shift — you stop thinking of errors as exceptional events and start treating them as valid return values. Once that clicks, it is hard to go back.
All code in this article is taken directly from FenixKit — a ready .NET 8 Minimal API starter kit with MongoDB that I built and packaged to stop rewriting the same foundation on every project.
References
- ErrorOr library by Amantinband — github.com/amantinband/error-or
- RFC 7807 — Problem Details for HTTP APIs — tools.ietf.org/html/rfc7807
- Microsoft Docs — Exception handling in .NET — learn.microsoft.com/dotnet/standard/exceptions
- Microsoft Docs — IExceptionHandler in ASP.NET Core — learn.microsoft.com/aspnet/core/fundamentals/error-handling
- Microsoft Docs — Minimal APIs in ASP.NET Core — learn.microsoft.com/aspnet/core/fundamentals/minimal-apis
Top comments (0)