DEV Community

Cover image for Why I stopped using exceptions for control flow in my .NET 8 APIs
fenixkit
fenixkit

Posted on

Why I stopped using exceptions for control flow in my .NET 8 APIs

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
});
Enter fullscreen mode Exit fullscreen mode

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.");
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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)
    };
}
Enter fullscreen mode Exit fullscreen mode

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."]
  }
}
Enter fullscreen mode Exit fullscreen mode

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."
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)