Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 6 min read

Return or throw? Result pattern vs exceptions in ASP.NET Core

The order does not exist. Now what? Every .NET API answers this question many times a day, and there are two camps. One throws a NotFoundException and lets something upstream turn it into a 404. The other returns the er

Return or throw? Result pattern vs exceptions in ASP.NET Core

The order does not exist. Now what?

Every .NET API answers this question many times a day, and there are two camps. One throws a NotFoundException and lets something upstream turn it into a 404. The other returns the error as a value. Both work. Both have real costs.

This post lists the pros and cons of each, shows numbers, and ends with the rule I use.

TL;DR

  • Return the errors a caller can expect: not found, conflict, validation failed. They are 4xx.
  • Throw what should never happen: a bug, a dead database. It is a 500.
  • You need both, and both should produce the same error shape.

The exception way

public async Task<OrderDto> GetAsync(int id, CancellationToken ct)
{
    var order = await repo.FindAsync(id, ct)
        ?? throw new NotFoundException($"Order {id} does not exist.");

    return order.ToDto();
}

Somewhere else, an IExceptionHandler or a middleware catches NotFoundException and writes the 404.

What is good

  • It is built in. Every .NET developer knows it and every library already uses it.
  • The happy path stays clean. There are no checks after each call.
  • It cannot be ignored by accident. If nobody catches it, the request fails loudly.
  • You get a stack trace for free.
  • It crosses ten layers with no plumbing.

What is bad

  • The signature hides the failure. Task<OrderDto> says nothing about "not found". C# has no checked exceptions, so the caller learns the failure cases from the docs, or from production.
  • It is slow next to a plain return. .NET 9 made exceptions 2 to 4 times faster in micro-benchmarks, and they are still far from free. Numbers are below.
  • Expected events look like crashes. A user asks for a missing order, and many monitoring tools count one more exception.
  • The mapping lives far from the code. One handler has to know every exception type and its status code. Forget one and the client gets a 500.

The Result way

public async Task<Result<OrderDto>> GetAsync(int id, CancellationToken ct)
{
    var order = await repo.FindAsync(id, ct);
    if (order is null)
        return Error.NotFound($"Order {id} does not exist.");

    return order.ToDto();
}

The method returns the value or an error. The caller checks which one it got.

What is good

  • The signature tells the truth. Result<OrderDto> says "this can fail", and the caller has to look before it reads the value.
  • It is cheap. An error is a normal return value, with no stack unwinding.
  • Expected errors stay out of your exception tracking. A 404 is not an incident.
  • Mapping to HTTP is mechanical. The error already knows its type, and the type knows its status code.
  • Tests are simple. Call the method, assert on what came back.

What is bad

  • Plumbing. Every caller has to check and pass the error up. The code is below.
  • It spreads. Once a service returns a Result, its callers do too.
  • There is no stack trace. You know what failed, not where.
  • It can be dropped. C# does not force you to use a return value. Call a method that returns a Result, forget to check it, and the error is gone without a warning. An exception never lets you do that.
  • There is no standard type. .NET has no built-in Result, so you pick a library or write your own. C# 15 unions give you the "value or error" shape, but you still write the HTTP mapping yourself.
  • You still need exceptions. EF Core throws, HttpClient throws, your own bugs throw. A Result does not replace the exception handler. You run two systems.

This is the plumbing, repeated at every level:

var order = await orders.GetAsync(orderId, ct);
if (!order.IsSuccess)
    return order.Error;

return await billing.ChargeAsync(order.Value, ct);

What it costs

I measured a 404 from a service three async calls deep, on .NET 11 with BenchmarkDotNet. "Whole request" is a full GET /orders/{id} through an in-memory TestServer.

A 404 as Alone Whole request
Thrown exception, caught by IExceptionHandler 4,834 ns, 2,808 B 16.6 ยตs, 19.6 KB
Returned Result<T> 68 ns, 64 B 5.2 ยตs, 12.4 KB

Alone, the throw costs about 70 times more. Inside a whole request, about 3 times more. It is one laptop, so read the ratio and not the absolute numbers. The method and the raw output are in the repository.

Be honest about what this means. If your endpoint calls a database, 11 microseconds is noise. Speed alone is a weak reason to switch. It starts to matter when errors are common: a public API scanned by bots, a lookup endpoint that mostly answers 404. Bad requests are the ones anybody can send for free, so it helps when they are also the cheapest ones to answer.

The stronger reasons are the other ones: an honest signature, and quiet monitoring.

So which one?

Both. They answer different questions.

Expected errors are returned and become 4xx. Unexpected ones are thrown and become 500. Both leave as one ProblemDetails shape.

  • Return what the caller can expect and act on: not found, conflict, validation failed, forbidden. These are part of the contract of your API. They are 4xx responses.
  • Throw what should never happen: a bug, a null where none can be, a database that is down. Nobody upstream can fix it. It is a 500, and somebody should get an alert.

A quick test: should this wake someone up at night? If yes, it is an exception. If the client just did something clients do, it is a result.

This has one practical consequence. You need both pipelines, and both must produce the same error shape. The client should not be able to tell which one wrote the response. In ASP.NET Core that shape is ProblemDetails (RFC 9457).

Where ResultCrafter fits

Wiring those two pipelines into one shape is boring work, and I got tired of doing it in every project. So I wrote ResultCrafter, a small open-source library for .NET 10 and later.

GitHub logo HaikAsatryan / ResultCrafter

Result pattern for ASP.NET Core: errors become the right status and RFC 9457 ProblemDetails in one call. NativeAOT-safe.

ResultCrafter

Return errors. Don't throw them. ResultCrafter gives your ASP.NET Core services a Result<T> and turns it into the right HTTP response in one call. Errors leave as clean RFC 9457 ProblemDetails, the same shape every time.

NuGet Downloads Build

The idea

Exceptions are for surprises. A missing order is not a surprise. It is Tuesday. So the service returns it:

public async Task<Result<OrderDto>> GetAsync(int id, CancellationToken ct)
{
    var order = await repo.FindAsync(id, ct);
    if (order is null)
        return Error.NotFound($"Order {id} does not exist.");

    return order.ToDto();
}

The endpoint maps it in one call:

app.MapGet("/orders/{id:int}", async (int id, OrderService orders, CancellationToken ct) =>
    (await orders.GetAsync(id,
โ€ฆ

dotnet add package ResultCrafter.AspNetCore
builder.Services.AddResultCrafter();

var app = builder.Build();
app.UseResultCrafter();

app.MapGet("/orders/{id:int}", async (int id, OrderService orders, CancellationToken ct) =>
    (await orders.GetAsync(id, ct)).ToOkResult());

The client gets a 200 with the order, or this:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "not_found",
  "status": 404,
  "detail": "Order 42 does not exist.",
  "instance": "/orders/42",
  "traceId": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00",
  "requestId": "0HN8K2MJ7F4QP:00000001"
}

What it does for you:

  • Returned errors get the right status: 400, 401, 403, 404, 409, 410, 422 and 429.
  • Unhandled exceptions become a clean 500 in the same shape, with full detail in Development and a plain message in Production.
  • It covers the "dropped result" weakness at the edge. Return a Result from an endpoint without mapping it and it fails loudly on the first run, instead of sending your error with a 200.
  • It logs 4xx at Warning and 5xx at Error.
  • It works with Minimal APIs and controllers, and it is NativeAOT-safe.

It is small on purpose: Map and Match, no Bind, no railway chains. If you want a full functional toolkit, or you are on an older .NET, ErrorOr, FluentResults and Ardalis.Result are good libraries. I keep an honest comparison table in the repository.

The package is ResultCrafter.AspNetCore on NuGet.

Your turn

Pick the rule first and the library second. Return what you expect. Throw what you do not.

Where do you draw the line in your APIs? Do you throw your 404s or return them? Tell me in the comments, especially if you disagree.

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.