Skip to content

Result Pattern or Exceptions for Errors? Wolverine Lets You Say "Neither"

Jeremy Miller8th June 2026

Wolverine is built with the philosophy of minimizing repetitive code ceremony and eliminating as much code noise as possible -- even at the cost of a little bit of "magic" that is admittedly not for everyone.

Yesterday I saw a tongue in cheek post going around on LinkedIn about ".NET Bingo" which was pretty spot on. Their list of common topics included Vertical Slice Architecture (which Wolverine makes simpler than other .NET technical stacks) and Modular Monoliths (which Wolverine also has very strong support for, but is also far more complex than I think the LinkedIn content creators ever discuss). This author was also poking at the clichéd discussion of "result pattern vs. exceptions" posts that frequently make the rounds in the .NET community. If you haven't seen them, start with Andrew Lock's Working with the result pattern series, this Exception vs. Result Pattern writeup, and Milan Jovanović's recurring take that "most .NET codebases don't have an exception problem, they have a control-flow problem."

The general thesis across all of them is reasonable: don't throw exceptions for predictable, non-exceptional outcomes like a requested entity doesn't exist or some data is out of the acceptable range. You know, normal, everyday validation errors or even authorization failures.

Wolverine is going to hold true to its philosophy of minimizing noise code and say that its preference between throwing exceptions and using custom Result<T> types is to choose neither.

First though, let's go on a little bit of a journey to talk about more traditional ways of handling validation errors or data missing conditions or just invalid state that would force you to end message or HTTP request handling, then look at how idiomatic Wolverine usage can simplify your application code.

Level-Setting: The Result Pattern Through a "Mediator"

Let's start with a very common shape today: a thin HTTP endpoint that uses an in-process mediator to dispatch the request to a handler, then unravels the handler's return value into an HTTP response. Wolverine can absolutely play the mediator role here through IMessageBus.InvokeAsync<T>().

First, a hand-rolled result type — a little discriminated union expressed with records:

csharp
public abstract record CreateOrderResult;
public record OrderCreated(Guid Id) : CreateOrderResult;
public record OrderCustomerNotFound : CreateOrderResult;
public record OrderInvalid(string[] Errors) : CreateOrderResult;

The handler returns one of those cases:

csharp
public static class CreateOrderHandler
{
    public static CreateOrderResult Handle(CreateOrder command, ICustomerLookup customers)
    {
        if (!customers.Exists(command.CustomerId))
            return new OrderCustomerNotFound();

        if (command.Quantity <= 0)
            return new OrderInvalid(["Quantity must be greater than zero"]);

        var id = Guid.NewGuid();
        // ... actually place the order ...
        return new OrderCreated(id);
    }
}

And the Minimal API endpoint dispatches through Wolverine-as-mediator, then does the unwrapping:

csharp
app.MapPost("/orders", async (CreateOrder command, IMessageBus bus) =>
{
    var result = await bus.InvokeAsync<CreateOrderResult>(command);

    return result switch
    {
        OrderCreated created       => Results.Ok(created),
        OrderCustomerNotFound      => Results.NotFound(),
        OrderInvalid invalid       => Results.BadRequest(invalid.Errors),
        _                          => Results.StatusCode(500)
    };
});

This works, and if you like it, you're welcome to keep doing it — Wolverine supports it just fine. But take stock of what it costs:

  • You wrote a bespoke result hierarchy that exists only to be pattern-matched and thrown away one line later.
  • The mapping from "domain outcome" to "HTTP status code" lives in the endpoint, divorced from the handler that actually knows what happened.
  • Every new outcome means touching the result type, the handler, and the switch. Miss a case and you fall through to a 500.
  • This method signature doesn't do anything at all to help you generate OpenAPI metadata for the HTTP endpoint, so there'll be more code than we're showing just to build up the necessary OpenAPI. Remember that point, because it's going to come up again later

That's the tax the Result pattern quietly charges on every single endpoint.

The IResult Shortcut Is "Mystery Meat"

A tempting way to cut the ceremony is to have the handler just return ASP.NET Core's IResult directly, and Wolverine will happily execute it:

csharp
public static class CreateOrderHandler
{
    public static IResult Handle(CreateOrder command, ICustomerLookup customers)
    {
        if (!customers.Exists(command.CustomerId))
            return Results.NotFound();

        if (command.Quantity <= 0)
            return Results.BadRequest(new[] { "Quantity must be greater than zero" });

        var id = Guid.NewGuid();
        // ... place the order ...
        return Results.Ok(new OrderCreated(id));
    }
}

app.MapPost("/orders", (CreateOrder command, IMessageBus bus)
    => bus.InvokeAsync<IResult>(command));

Look how short that endpoint got! But IResult is mystery meat. The signature tells you — and more importantly, tells tooling — absolutely nothing. What status codes can this endpoint return? What's the response body shape on success? What does a failure look like? IResult could be anything. It communicates zero intent to the next developer, and it gives OpenAPI generation nothing to work with. Your Swagger UI will show a single nondescript 200 with no schema, which is worse than useless because it's actively misleading. You've made the code shorter by making it opaque.

OneOf<> Buys Back the Metadata — But It's Ugly as Sin

The discriminated-union libraries like OneOf exist precisely to solve the "mystery meat" problem. The return type re-acquires its meaning, and Wolverine.HTTP can read it to generate real OpenAPI metadata:

csharp
public static class CreateOrderHandler
{
    public static OneOf<OrderCreated, NotFound, ValidationProblem> Handle(
        CreateOrder command, ICustomerLookup customers)
    {
        if (!customers.Exists(command.CustomerId))
            return new NotFound();

        if (command.Quantity <= 0)
            return new ValidationProblem(new() { ["Quantity"] = ["Must be greater than zero"] });

        var id = Guid.NewGuid();
        // ... place the order ...
        return new OrderCreated(id);
    }
}

app.MapPost("/orders", async (CreateOrder command, IMessageBus bus) =>
{
    var result = await bus
        .InvokeAsync<OneOf<OrderCreated, NotFound, ValidationProblem>>(command);

    return result.Match(
        created  => Results.Ok(created),
        notFound => Results.NotFound(),
        invalid  => Results.BadRequest(invalid.Errors));
});

The good news: the types are honest again, and the OpenAPI document is accurate. The bad news: read that code back. The OneOf<OrderCreated, NotFound, ValidationProblem> type appears twice — once on the handler and once on the caller — and any time you add a fourth outcome you edit it in two places plus the .Match(). The .Match() lambda block has roughly the same signal-to-noise ratio as the switch we started with. It is, to put it plainly, ugly as sin. You've spent a generic type parameter list and a multi-line match expression to tell the framework something it could often have figured out on its own.

Hold that thought.

It's certainly possible that when first class discriminated unions hit C# that the approach above will be less painful to use

When Exceptions Are Exactly Right

I want to be very clear that I am not anti-exception. Exceptions are the correct tool for actual failures — the database is down, an invariant you believed was guaranteed turned out to be violated, a downstream service returned garbage. These are not part of your normal control flow, and you should not be modeling them as Result<T>.

Where Wolverine shines here is the message handler side, where a thrown exception isn't an awkward control-flow hack — it's the input to a genuinely powerful resiliency engine. You configure error handling policies once, declaratively, and then your handlers get to throw and stay clean:

csharp
builder.UseWolverine(opts =>
{
    // A poison message that can never succeed — don't retry, just drop it
    // (optionally to the dead letter queue). Exceptions ARE the control signal here.
    opts.OnException<InvalidOrderException>().Discard();

    // A transient infrastructure failure — retry with backoff, then dead-letter
    opts.OnException<NpgsqlException>()
        .RetryWithCooldown(50.Milliseconds(), 100.Milliseconds(), 250.Milliseconds());

    // Optimistic concurrency? Just requeue and try again
    opts.OnException<ConcurrencyException>().RequeueAndRetry(3);
});

The handler itself throws and moves on:

csharp
public static class PlaceOrderHandler
{
    public static OrderPlaced Handle(PlaceOrder command)
    {
        if (command.Quantity <= 0)
            throw new InvalidOrderException("Quantity must be positive");

        // ... happy path only, no error plumbing ...
        return new OrderPlaced(command.OrderId);
    }
}

This is the case where the "exceptions are slow / exceptions hide intent" arguments mostly fall away. In asynchronous messaging you are already off the hot path, the exception is genuinely describing a failure, and Wolverine turns it into retries, scheduled retries, requeues, dead-lettering, or a flat-out discard — all without a single try/catch or Result<T> in your handler. That's exceptions as a feature, not a smell.

On the HTTP side, the modern equivalent of the old MVC IExceptionFilter is ASP.NET Core's built-in ProblemDetails + IExceptionHandler pipeline, and since Wolverine.HTTP endpoints are ordinary ASP.NET Core endpoints, it composes cleanly. You can map a thrown exception type to a status code globally and keep the exception out of your endpoint signature entirely:

csharp
builder.Services.AddProblemDetails();

builder.Services.AddExceptionHandler<DomainExceptionHandler>();

// One place that turns domain exceptions into ProblemDetails responses
public class DomainExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, Exception exception, CancellationToken ct)
    {
        var (status, title) = exception switch
        {
            EntityNotFoundException => (404, "Not Found"),
            InvalidOrderException   => (400, "Validation Failed"),
            _                       => (0, "")
        };

        if (status == 0) return false; // not ours; let it bubble

        await Results.Problem(statusCode: status, title: title)
            .ExecuteAsync(context);
        return true;
    }
}

This is legitimately tidy, and for genuine error conditions it's a fine pattern. The catch — and the reason it's still not my default for expected outcomes — is that a "customer not found" or "quantity must be positive" is not really an exceptional condition, and you're still paying to throw, capture a stack trace, and unwind the stack for something you fully expected to happen.

Wolverine Prefers Neither

I want it to be as clear as possible than Wolverine is able to derive OpenAPI metadata from the signature and usage of validation middleware in every HTTP endpoint shown in this post, including the possibility of a 404 NotFound or a 400 ProblemDetails response as well as the actual response body signature for a 200

Here's the part I actually want to sell you on. For the vast majority of endpoints, the choice isn't "Result pattern vs. exceptions" at all. Wolverine.HTTP gives you a third door where the framework handles the common error responses for you, your endpoint method stays focused on the happy path, and the OpenAPI metadata comes out correct without you decorating anything by hand.

404 With No Mediator, No Result Type, No Exception

If an endpoint's job is "load this entity or 404," you don't need any of the machinery above. The [Entity] attribute loads the entity from your persistence (Marten, EF Core, etc.) using the route argument, and Required = true is the default behavior — if the entity isn't found, Wolverine short-circuits with a 404 before your method ever runs:

csharp
[WolverineGet("/orders/{id}")]
public static Order Get([Entity(Required = true)] Order order) => order;

That's the whole endpoint. No Result<T>, no switch, no thrown exception. The 404 path is handled, the success path returns the Order (and Wolverine knows the response type for OpenAPI), and the body of the method only ever deals with the case where the order genuinely exists. If you need to opt out, [Entity(Required = false)] gives you a nullable parameter to handle however you like.

400 + ProblemDetails With a Clean Endpoint Body

For validation, add the FluentValidation middleware package:

bash
dotnet add package WolverineFx.Http.FluentValidation
csharp
app.MapWolverineEndpoints(opts =>
{
    opts.UseFluentValidationProblemDetailMiddleware();
});

Now write a perfectly ordinary validator:

csharp
public class CreateOrderValidator : AbstractValidator<CreateOrder>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.Quantity).GreaterThan(0);
        RuleFor(x => x.CustomerId).NotEmpty();
    }
}

...and the endpoint stays blissfully unaware that validation even happened:

csharp
[WolverinePost("/orders")]
public static (OrderCreated, IStartStream) Create(CreateOrder command)
{
    var id = Guid.NewGuid();
    var created = new OrderCreated(id);
    return (created, MartenOps.StartStream<Order>(id, created));
}

When validation fails, Wolverine returns an application/problem+json 400 response with the proper ProblemDetails body — and your method body never runs, never sees a Result<T>, and never pays for a thrown exception. Critically, the FluentValidation middleware also contributes that 400 response to the OpenAPI metadata. Your Swagger document knows the endpoint can return a 400 ProblemDetails because Wolverine learned it from the registered validator, not because you hand-annotated .Produces(400).

Lightweight Validate() for the Cases That Don't Need FluentValidation

Sometimes a full validator is overkill and you just want to stop early with a couple of guard checks. Wolverine.HTTP recognizes a Validate method that returns IEnumerable<string> (or string[]); any non-empty result becomes a 400 ProblemDetails automatically:

csharp
public static class ShipOrderEndpoint
{
    public static IEnumerable<string> Validate(ShipOrder command)
    {
        if (command.TrackingNumber.IsEmpty())
            yield return "A tracking number is required";

        if (command.Carrier is null)
            yield return "A carrier must be specified";
    }

    [WolverinePost("/orders/ship")]
    public static OrderShipped Post(ShipOrder command)
        => new OrderShipped(command.OrderId);
}

The same lightweight, low-ceremony idea carries over to message handlers, where a Before method returning a HandlerContinuation can stop processing early without throwing:

csharp
public static class ShipOrderHandler
{
    public static HandlerContinuation Before(ShipOrder command, ILogger logger)
    {
        if (string.IsNullOrWhiteSpace(command.TrackingNumber))
        {
            logger.LogInformation("Skipping {OrderId} — no tracking number", command.OrderId);
            return HandlerContinuation.Stop;
        }

        return HandlerContinuation.Continue;
    }

    public static OrderShipped Handle(ShipOrder command)
        => new OrderShipped(command.OrderId);
}

In both cases the actual handler method is left with nothing but its real work.

The Payoff: Correct OpenAPI Without IResult or OneOf<>

Put those pieces together and look at what Wolverine.HTTP can infer about an endpoint without you returning an IResult, building a OneOf<>, or writing a single .Produces() call:

  • The 200 success response and its body schema come from the endpoint's plain return type.
  • The 404 comes from [Entity(Required = true)].
  • The 400 ProblemDetails comes from the registered FluentValidation rules and/or the Validate() method.

That's a fully-described OpenAPI operation derived from code that reads like the happy path and nothing else. We bought back all the metadata that the IResult approach threw away, and we did it without the eyesore of the OneOf<> approach. The signal-to-noise ratio is the whole point: your code says what the feature does, and the framework handles the predictable detours.

So About That New Result<T> Support...

This support was added mostly for the combination of Wolverine with the Hot Chocolate framework where you pretty well have to use Wolverine as a "mediator" tool and the Hot Chocolate mutation or query needs to be handled differently depending on what Wolverine did. I'm (Jeremy) sorely tempted to revisit the possibility of a first class Hot Chocolate extension to make the Wolverine usage more idiomatic and remove the need for the silly Result stuff

In the interest of full disclosure: Wolverine recently grew formal support for the Result pattern. You can register a result type — FluentResults, SimpleResults, or your own — and Wolverine will unwrap it for you on the way out of a handler:

csharp
opts.UseResultType(
    typeof(Result<>),
    stopWhen:    x => ((ResultBase)x).IsFailed,
    unwrapWith:  x => ((dynamic)x).ValueOrDefault,
    errorsFrom:  x => ((ResultBase)x).Errors.Select(e => e.Message));
csharp
public static class CreateOrderHandler
{
    public static Result<OrderPlaced> Handle(CreateOrder cmd, OrdersBook book)
    {
        if (cmd.Quantity <= 0) return Result.Fail<OrderPlaced>("Quantity must be positive");
        book.Placed.Add(cmd.OrderId);
        return Result.Ok(new OrderPlaced(cmd.OrderId));
    }
}

// On success you get the inner value; on failure Wolverine throws ResultFailureException
var placed = await bus.InvokeAsync<OrderPlaced>(new CreateOrder("o-1", 5));

We added this because enough teams are already invested in a Result library that asking them to abandon it was a non-starter, and because meeting people where they are is part of Wolverine's job. So if you have standardized on FluentResults across your codebase, this is here for you and it's first-class.

But if you were asking me: I do not recommend reaching for this in the majority of cases. I'd instead push you instead toward Wolverine's "Railway Programming (Kind Of)" support. The Wolverine team very strongly recommends against threading result types through your handlers as a default architecture. If a value isn't found, lean on [Entity(Required = true)]. If a request is invalid, lean on validation that produces ProblemDetails. If something genuinely fails, throw, and let Wolverine's error-handling policies turn that exception into the resiliency behavior you want. Save the Result<T> machinery for the specific spots where it earns its keep, not as a tax you pay on every method.

Wrapping Up

We think Wolverine can give you cleaner and simpler code with its idioms than you can with any usage of the "mediator" pattern within ASP.Net Core endpoints and eliminate any need for the code noise that comes with Result<T> types.

With that being said, the Wolverine team's recommendations are to:

  • Honestly, we'll recommend skipping any kind of "Wolverine as Mediator" usage in most cases and we'll definitely advise you to just put message or HTTP handling directly in message handlers or HTTP endpoint methods instead of delegating to nested service layers with or without a "Mediator"
  • Always try to keep your call stack short and avoid deeply nested IoC dependency chains. I.e., maybe dial back on ye olde Clean Architecture all the things! approach that's so prevalent in enterprise .NET applications
  • Use exceptions for actual failures — and let Wolverine's message handler error handling make them a resiliency feature instead of a liability.
  • Skip the Result pattern as a default. IResult is mystery meat, OneOf<> is well-meaning, but ugly, and a hand-rolled result hierarchy is a tax on every endpoint.
  • Let Wolverine handle the common cases — [Entity] for 404s, validation for 400s — so your endpoints read like the happy path and your OpenAPI metadata still comes out correct.

That's it. Write less code, say more with your types, and let the framework handle the predictable detours. If you want to see it end-to-end, the Wolverine.HTTP docs are the place to start, and as always you can find us in the Critter Stack Discord if you want to argue with me about any of this.

RSS Feed · All Rights Reserved.