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.