Better .NET Controllers with OneOf

Published: Dec 5, 2025

Last updated: Dec 5, 2025

In this blog post, we will be looking at error handling for .NET applications and looking at how we can help write our services to invert control back to our controllers for when it comes to making request responses using the OneOf library, a library that provides F# style unions.

Context setting

For anyone not interested in the backstory, you can skip along.

For anyone that follows my content, you will know that I am not a C# nor .NET developer. My experience with the language itself is limited to time spent working with C# at university or through hobby projects (game development, Arduino).

That being said, work has recently asked me to make some changes to our old .NET 6 server. Some of the challenges with this legacy code base has been tracking error management and understanding potential return types where the current style of relying on raising exceptions for control flow lacks intellisense for developers working on the codebase.

It should be disclosed that as an unseasoned C# developer, this can make adjudicating good practice and idiomatic C# a little more difficult. On the flip-side of this, one could see it as me looking at C# with a fresh set of eyes.

Reviewing the .NET error handling page

One of the challenges I was facing while reading through the legacy codebase was understanding how our non-2xx error code responses were happening. A lot of responses were returning as 2xx series responses with null values, even in scenarios where the request body itself was invalid.

I opted to first read through the recommendations for error handling that the .NET documentation presents (here I am sharing the latest version of the docs, although I was looking through .NET 6 documentation).

Something that stood out to me was the lambda exception handler where we could pass a C# handler to the app.UseExceptionHandler method:

var app = builder.Build(); if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler(exceptionHandlerApp => { exceptionHandlerApp.Run(async context => { context.Response.StatusCode = StatusCodes.Status500InternalServerError; // using static System.Net.Mime.MediaTypeNames; context.Response.ContentType = Text.Plain; await context.Response.WriteAsync("An exception was thrown."); var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>(); if (exceptionHandlerPathFeature?.Error is FileNotFoundException) { await context.Response.WriteAsync(" The file was not found."); } if (exceptionHandlerPathFeature?.Path == "/") { await context.Response.WriteAsync(" Page: Home."); } }); }); app.UseHsts(); }

In the example above, the code could be configured within a .NET application to capture raised exceptions and use control flow to act upon them.

In particular, the example code sets the 500 status code and conditionally sends responses from the server based on business logic.

The challenges that raising exceptions presents

The current .NET configuration for the application that I was working with had a version of this configured, but this is where abstract boolean logic itself can take a nasty turn. The file itself is a minefield of nested if/else statements where the final response is "funneled" through that logic based on the type of exception it is (similar to exceptionHandlerPathFeature?.Error is FileNotFoundException above), as well as some business-specific control flow qualifiers (statements not based on the error type).

Contributing to the file itself would be adding to the debt that had built up, but in some cases for legacy code that may be considered fine (and may actually end up being what I am requested to do), but there are two reasons why I struggle with this style of controller management:

  1. Type signatures do not inform the developer of potential raised exceptions.
  2. Controllers should be the entry point and exit point for all expected HTTP responses.

It is of my personal belief that controllers should be the "air traffic controllers" - they should handle the delegating of business logic to the services, while be in control of returning all expected responses.

What I mean by this is that, as a developer, if I am wanting to look at how an endpoint is handled, I should be able to start at the controller in order to grok how data is handled coming in while also understanding all expected responses going out -- not just the "ok" response.

When I say "expected responses", that includes management for all non-200 responses for percievable issues such as request validation failure, unprocessible issues due to dependencies, etc. There is always the possibility for unexpected errors (which I normally refer to as defects), and in my opinion only these errors should fall outside of the domain of controller management.

The code shared above for the recommended app.UseExceptionHandler I find to be appropriate defects. I would use these for unforeseen circumstances, configure the 500 internal error response and have any 3rd party error tracking occur in this place only. Everything else should be managed by the controller as far as I am concerned.

Return types in .NET

The first of the two reasons that I presented for why raising exceptions is challenging is that the type signatures do not reflect the return type.

Take the following code example:

public async Task<string> DecryptCipherText(string cipherText) { if (string.IsNullOrEmpty(cipherText)) { throw new ArgumentNullException(nameof(cipherText), "Cipher text cannot be null or empty"); } // ... pretend to process things return "Processed cipherText"; }

In this code block, we have a contrived example of a function that expects to decrypt some cipher text. In the scenario that the cipherText passed in is null or empty, we raise an ArgumentNullException.

However, if we look at the type signature, all we see is (awaitable) Task<string> CardDataEncryption.DecryptCipherText(string cipherText). There is no indication to anyone from the call site of the function (be it another service or whatever) that an ArgumentNullException can be thrown.

While this is not unique to C# itself, there are alternatives that offer up solutions for us. This is where I want to introduce OneOf.

As mentioned as the beginning of the post, OneOf is a library that provides F# style unions for C#.

If we look at our function again with the return signature Task<string>, then we note that out-of-the-box, there is no "union" style returns where we could indicate different return types. This is where OneOf steps in.

It enables us to be able to alter signatures to indicate multiple return types and provides tools to help us match on those return types.

For example, we could alter our code to the following:

public async Task<OneOf<string, ArgumentNullException>> DecryptCipherText(string cipherText) { if (string.IsNullOrEmpty(cipherText)) { // Note that we are now returning this value return new ArgumentNullException(nameof(cipherText), "Cipher text cannot be null or empty"); } // ... pretend to process things return "Processed cipherText"; }

In the above, instead of raising an exception, we can now return the exception.

Looking at the type signature now, we see it's adjusted to (awaitable) Task<OneOf<string, ArgumentNullException>> CardDataEncryption.DecryptCipherText(string cipherText).

From a developer point-of-view, Intellisense providing more information on potential errors that can go wrong is a powerful to invert control of how to manage results from a function from the caller. This is particularly powerful for developers like myself who stumble into a codebase for the first time.

The library itself provides a number of ways to fetch values from this response type, but the two that I use the most are Match, TryPickT... or directly accessing the value itself with the value property AsT... where the ... in both cases represents a number for the argument order passed to the type signature (i.e. AsT0 for our example would be string while AsT1 would be ArgumentNullException).

For example, with our function defined above we could access the results like so:

const result = await DecryptCipherText('someCipherTextValue'); // Using match to determine to send a 200 or 400 return result.Match( result => Ok(result), ArgumentNullException => new BadRequest("...") ); // Using TryPick - where you might want to pick out the error to return early and bubble up the error if (thingOrNotFoundOrError.TryPickT1(out ArgumentNullException error, out var remainder)) { return error; } // Accessing the property directly Console.WriteLine(result.AsT0); // Possibly the string!

The above demonstrates the few ways that I've used the library.

  1. The first example with Match is how I managed the "air traffic controller" for responding from the controller. The Match type is exhaustive and requires you to handle all expected responses.
  2. The second example allows you to "extract" a value to use as required. Normally for myself, this might be early propagation of the error up to where it can be handled (normally at the controller).
  3. Demonstrates access the value directly. I normally use this in tandem with (2) to ensure that the value exists before applying more logic to it (or you could do the opposite and pick out the valid response and return all others!).

The library itself extends possible return values as you have more applied. For a very contrived example of this, the following would have AsTN where N could be 0 to 4:

(awaitable) Task<OneOf<string, ArgumentNullException, ArithmeticException, ArgumentException, PaymentException>> CardDataEncryption.DecryptCipherText(string cipherText)

To nail the point home - the value of having a separation of "expected" and "unexpected" errors is that the former allows you to self-document and manage all expected responses in a matter-of-fact way.

Keeping that separation can even enable things such as more effective testing given that the domain and range of a function makes the testing strongly in favour of "input-output testing". It means functions will no longer contain the side-effects of thrown functions that do not return, which is heavensent when it comes to testing functions orientated around business logic.

Conclusion

Today's blog post introduces the idea of using the library OneOf in order to better manage union response types, enabling the control flow and development of C# to focus on handling expected outcomes.

This was demonstrated by following along an example that I had personally come across where reliance on control flow with throwing exceptions leads to a more difficult developer experience and merging of error state management across different concerns.

Photo credit: wolfgang_hasselmann

Personal image

Dennis O'Keeffe

Byron Bay, Australia

Share this post

Recommended articles

Dennis O'Keeffe

2020-present Dennis O'Keeffe.

All Rights Reserved.