Using Result<T> or OneOf<T> for Better Error Handling in .NET
Are you tired of debugging vague errors or overusing try-catch? Traditional exception-based workflows can be messy, highly compromising code readability and maintainability. In this post, I will introduce you to Result<T>
and OneOf<T>
. Two powerful patterns that make success and failure contracts explicit. Whether you're returning data from a service or validating input, Result<T>
provides a clear success/failure contract, while OneOf<T>
allows for even more expressive return types by supporting multiple result possibilities.

What is Result<T> in .NET?
Result<T>
is the return type of an operation that represents whether the operation succeeded or failed. If everything is successful, it contains output with a value of type T
, otherwise, it contains failure information, usually an error message.
What is OneOf<T> in .NET?
OneOf<T>
or OneOf<T1, T2, T...>
represents a discriminated union containing all possible returns of an operation or a method. It contains an array of types, allowing a method to return one of several defined possibilities. The OneOf pattern provides you with fine-grained control and type safety.
How to implement Result<T> in an ASP.NET Core API
To show the usage of Result<T>
in an API project, I will walk through a project step by step. The solution will employ CSharpFunctionalExtensions
, a battle-tested Result<T>
library by Vladimir Khorikov. Let's create a project named ResultNOneOf
.
Step 1: Install NuGet packages
dotnet add package Microsoft.EntityFrameworkCore.InMemory
dotnet add package CSharpFunctionalExtensions
Step 2: Create a model
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
Step 3: Configure AppDbContext
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<Author> Authors => Set<Author>();
}
Step 4: Set up service class
The service class acts as both our data access and validation layer:
using CSharpFunctionalExtensions;
using Microsoft.EntityFrameworkCore;
namespace ResultNOneOf;
public class AuthorService
{
private readonly AppDbContext _dbContext;
public AuthorService(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Result> CreateAsync(Author author)
{
if (string.IsNullOrWhiteSpace(author.Name))
return Result.Failure("Name is required");
if (await _dbContext.Authors.AnyAsync(a => a.Email == author.Email))
return Result.Failure("Email already exists");
_db.Authors.Add(author);
await _dbContext.SaveChangesAsync();
return Result.Success();
}
public async Task<Result<Author>> GetByIdAsync(int id)
{
var author = await _dbContext.Authors.FindAsync(id);
return author == null
? Result.Failure<Author>("Author not found")
: Result.Success(author);
}
public async Task<Result<List<Author>>> GetAllAsync()
{
var authors = await _dbContext.Authors.ToListAsync();
if (authors.Count == 0)
return Result.Failure<List<Author>>("No authors found");
return Result.Success(authors);
}
}
Step 5: Create Controller
Exposing endpoints in the API controller.
using Microsoft.AspNetCore.Mvc;
namespace ResultNOneOf.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthorsController : ControllerBase
{
private readonly AuthorService _service;
public AuthorsController(AuthorService service)
{
_service = service;
}
[HttpPost("CreateAuthor")]
public async Task<IActionResult> Create(Author author)
{
var result = await _service.CreateAsync(author);
if (result.IsSuccess)
return Ok("Author created");
return BadRequest(result.Error);
}
[HttpGet("GetAuthorById{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var result = await _service.GetByIdAsync(id);
if (result.IsSuccess)
return Ok(result.Value);
return NotFound(result.Error);
}
[HttpGet("GetAllAuthors")]
public async Task<IActionResult> GetAll()
{
var result = await _service.GetAllAsync();
if (result.IsSuccess)
return Ok(result.Value);
return NotFound(result.Error);
}
}
Step 6: Configure Program.cs
using Microsoft.EntityFrameworkCore;
using ResultNOneOf;
var builder = WebApplication.CreateBuilder(args);
// In-Memory DB
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseInMemoryDatabase("AuthorsDb"));
// Register Services
builder.Services.AddScoped<AuthorService>();
// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddControllers();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// Enable HTTPS redirection, routing, and controllers
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
I am using an in-memory database to avoid additional complexities. You can see the dependency injection of AuthorService
here.
Step 7: Run and Test
Upon running, we get the Swagger UI:

The CreateAuthor
endpoint looks like this:

On everything posted correctly, the endpoint returns Result.Success:

Erroneous case 1: Name missing
As I have added the Name
property as mandatory in the service, we expect Result.Failure
with a descriptive message when no name is provided for the endpoint:


Erroraneus case 2: Existing email
Similarly, the Email
property should be unique as set in the service; we expect the Result.Failure with a descriptive message:


Here is the GetAllAuthors
endpoint in Swagger UI:

It returns an array of authors in the database:

And, to get a single author, there's the GetAuthorById
endpoint:

Which returns a single author:

Erroneous case
Passing the incorrect ID that does not belong to any existing data:


Hence, 'Author not found' with status code 404 is returned.
How to implement OneOf<T> in an ASP.NET Core API
Let us continue to build on the same example project to understand OneOf<T>
.
Step 1: Install the required NuGet package
dotnet add package OneOf
Step 2: Author Model creation
Same as above
Step 3: Configure AppDbContext
Same as above
Step 4: Create Service
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using OneOf;
using OneOf.Types;
namespace ResultNOneOf;
public class AuthorService
{
private readonly AppDbContext _dbContext;
public AuthorService(AppDbContext db)
{
_dbContext = db;
}
public async Task<OneOf<Success, string>> CreateAsync(Author author)
{
if (string.IsNullOrWhiteSpace(author.Name))
return "Name is required";
if (string.IsNullOrWhiteSpace(author.Email))
return "Email is required";
if (!IsValidEmail(author.Email))
return "Invalid email format";
if (await _dbContext.Authors.AnyAsync(a => a.Email == author.Email))
return "Email already exists";
_dbContext.Authors.Add(author);
await _dbContext.SaveChangesAsync();
return new Success();
}
public async Task<OneOf<Author, string>> GetByIdAsync(int id)
{
var result = await _dbContext.Authors.FindAsync(id);
return result is not null ? author : "Author not found";
}
public async Task<OneOf<List<Author>, string>> GetAllAsync()
{
var result = await _dbContext.Authors.ToListAsync();
return result.Count > 0 ? authors : "No authors found";
}
private bool IsValidEmail(string email)
{
return Regex.IsMatch(email,
@"^[^@\s]+@[^@\s]+\.[^@\s]+$",
RegexOptions.IgnoreCase);
}
}
For the CreateAsync
method, I am setting an array possibility with two values: either Success or a string containing the error description. In the second method of GetByIdAsync
, the return type expects either an Author
object or a string error message. Lastly, GetAllAsync
either returns a List
of Author
objects if the fetch is successful or outputs an error message stating "No authors found."
Step 5: Create Controller
using Microsoft.AspNetCore.Mvc;
namespace ResultNOneOf.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthorsController : ControllerBase
{
private readonly AuthorService _authorService;
public AuthorsController(AuthorService authorService)
{
_authorService = authorService;
}
[HttpPost("CreateAuthor")]
public async Task<IActionResult> Create(Author author)
{
var result = await _authorService.CreateAsync(author);
return result.Match<IActionResult>(
_ => Ok("Author created"),
error => BadRequest(error)
);
}
[HttpGet("GetAuthorById{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var result = await _authorService.GetByIdAsync(id);
return result.Match<IActionResult>(
author => Ok(author),
error => NotFound(error)
);
}
[HttpGet("GetAllAuthors")]
public async Task<IActionResult> GetAll()
{
var result = await _authorService.GetAllAsync();
return result.Match<IActionResult>(
authors => Ok(authors),
error => NotFound(error)
);
}
}
I also had to change the controller. While returning the response from the previous layer, wrapping the result for each return type specified in the OneOf
. Note that I have defined lambdas and prepared the API response accordingly with the appropriate status code.
Step 6: Configure Program.cs
Same as above
Step 6: Test and run
Upon running, we get the Swagger UI:

The CreateAuthor endpoint looks similar to the previous one:

And returns a status code 200 and a message when an author is created:

Erroneous case 1: Name missing
Let us repeat the attempt of creating an author without a name:


Erroraneus case 2: Existing email
Similarly, passing an incorrect email to test the validation:


The API response contains a status code 400 with the error message from the service layer.
Erroraneus case 3: Incorrect email address
When calling the endpoint with an invalid value in email:

End endpoint returns a validation error:

The GetAllAuthors
endpoint works in the same way as above, why I won't show the screenshots again here.
Let's instead take a look at the GetAuthorById
endpoint, which returns a single author by its ID.
Erroneous case 1


As above, the endpoint returns a status code 404 alongside a message indicating that the user was not found.
Hence, we can see that the implementation of both Result<T>
and OneOf<T>
validates and returns responses effectively. OneOf
is doing the job more expressively. I specified that the Create
method will either return Success or a string describing the error. Similarly, in get methods, they will either output a data object or an error message.
Custom Result class
Defining your own result class is a great idea to achieve more control and flexibility in your project. Just change the necessary code.
Step 1: Create Result class
namespace ResultNOneOf;
public class Result
{
public bool IsSuccess { get; }
public string Error { get; }
public bool IsFailure => !IsSuccess;
protected Result(bool isSuccess, string error)
{
if (isSuccess && error != string.Empty)
throw new InvalidOperationException("Success result cannot have an error message.");
if (!isSuccess && string.IsNullOrWhiteSpace(error))
throw new InvalidOperationException("Failed result must have an error message.");
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, string.Empty);
public static Result Failure(string message) => new(false, message);
}
public class Result<T> : Result
{
public T Value { get; }
private Result(bool isSuccess, T value, string error)
: base(isSuccess, error)
{
Value = value;
}
public static Result<T> Success(T value) => new(true, value, string.Empty);
public static new Result<T> Failure(string message) => new(false, default!, message);
}
Step 2: Change the Service
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using ResultNOneOf;
public class AuthorService
{
private readonly AppDbContext _db;
public AuthorService(AppDbContext db)
{
_db = db;
}
public async Task<Result> CreateAsync(Author author)
{
if (string.IsNullOrWhiteSpace(author.Name))
return Result.Failure("Name is required");
if (string.IsNullOrWhiteSpace(author.Email))
return Result.Failure("Email is required");
if (!IsValidEmail(author.Email))
return Result.Failure("Invalid email format");
if (await _db.Authors.AnyAsync(a => a.Email == author.Email))
return Result.Failure("Email already exists");
_db.Authors.Add(author);
await _db.SaveChangesAsync();
return Result.Success();
}
public async Task<Result<Author>> GetByIdAsync(int id)
{
var author = await _db.Authors.FindAsync(id);
return author == null
? Result<Author>.Failure("Author not found")
: Result<Author>.Success(author);
}
public async Task<Result<List<Author>>> GetAllAsync()
{
var authors = await _db.Authors.ToListAsync();
return authors.Count == 0
? Result<List<Author>>.Failure("No authors found")
: Result<List<Author>>.Success(authors);
}
private bool IsValidEmail(string email)
{
return Regex.IsMatch(email,
@"^[^@\s]+@[^@\s]+\.[^@\s]+$",
RegexOptions.IgnoreCase);
}
}
Step 3: Revise the Controller
using Microsoft.AspNetCore.Mvc;
namespace ResultNOneOf.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthorsController : ControllerBase
{
private readonly AuthorService _service;
public AuthorsController(AuthorService service)
{
_service = service;
}
[HttpPost("CreateAuthor")]
public async Task<IActionResult> Create(Author author)
{
var result = await _service.CreateAsync(author);
if (result.IsSuccess)
return Ok("Author created");
return BadRequest(result.Error);
}
[HttpGet("GetAuthorById{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var result = await _service.GetByIdAsync(id);
if (result.IsSuccess)
return Ok(result.Value);
return NotFound(result.Error);
}
[HttpGet("GetAllAuthors")]
public async Task<IActionResult> GetAll()
{
var result = await _service.GetAllAsync();
if (result.IsSuccess)
return Ok(result.Value);
return NotFound(result.Error);
}
}
When to use Result<T> and OneOf<T>
Now comes the million-dollar question: Which of the two should you use?
Result<T>
is suitable when you want to return a binary workflow that can either return an error message or a value (but not multiple types of values).
OneOf<T>
works best when you need an array of possible return types, such as multiple exception classes or messages. OneOf
allows including various outputs that may represent different status codes.
Conclusion
Error handling and input validation are integral parts of any application. The task can be messy and sometimes lead to further errors if not appropriately handled. I introduced Result<T>
and OneOf<T>
as more readable, clear, and consistent workflow patterns. In the post, we went through how each of them can elevate your code and how to choose between them.
elmah.io: Error logging and Uptime Monitoring for your web apps
This blog post is brought to you by elmah.io. elmah.io is error logging, uptime monitoring, deployment tracking, and service heartbeats for your .NET and JavaScript applications. Stop relying on your users to notify you when something is wrong or dig through hundreds of megabytes of log files spread across servers. With elmah.io, we store all of your log messages, notify you through popular channels like email, Slack, and Microsoft Teams, and help you fix errors fast.
See how we can help you monitor your website for crashes Monitor your website