C# · · 4 min read

Good practices with FluentResult

A short write up of what I consider good practices when using FluentResult with ASP.NET Core... to be continued

Good practices with FluentResult
Photo by Brett Jordan / Unsplash

Introduction

This is a short write up of what I consider good practices when using FluentResult with ASP.NET Core and the result pattern.

If you haven't heard of it before, FluentResults is a result-based error handling library for .NET that replaces exceptions-as-control-flow with explicit success/failure objects, implementing the result pattern.

GitHub - altmann/FluentResults: A generalised Result object implementation for .NET/C#
A generalised Result object implementation for .NET/C# - altmann/FluentResults

Use strongly-typed erros

Avoid passing simple strings to Result.Fail(). Instead, create classes for domain-specific failure scenarios. This allows you to check for specific error types later in the UI or API layer.

public class UserUnderageError(int age) : Error($"User is {age}, but must be 18.")
{
    public int Age { get; set; } = age;
}

// Usage
if (age < 18) return Result.Fail(new UserUnderageError(age));

Distinguish between Error and ExceptionalError

FluentResult has a build-in ExceptionalError. Use it when a result fails because of an actual Exception you caught. This preserves the stack trace.

try {
    // ...
} catch (Exception ex) {
    return Result.Fail(new ExceptionalError(ex));
}

Alternatively you can use CausedBy, like so

try {
    // ...
} catch (Exception ex) {
    return Result.Fail(new Error(ex.Message).CausedBy(ex);
}

The structure of the Errors property will look slightly different

Enrich errors by wrapping as a single chain using CausedBy

Instead of adding new top-level errors and keeping the previous one like so...

var result = SomeOp();

return Result.Fail(new Error("Some low level operation failed"))
             .WithErrors(result.Errors):

... prefer wrapping as a single chain using CausedBy which keeps exactly one top-level error and the previosu errors are attached as the causes.

This preserves nesting and keeps the error list clean. It aligns well with the Clean Architecture boundaries and you can render a clean "stack" or failures later.

private static Result Wrap(Result inner, string message, object? meta = null)
{
    var outer = new Error(message);

    if (meta is not null)
        outer.WithMetadata("Context", meta);

    // keep the original errors as causes (nested), not siblings
    foreach (var e in inner.Errors)
        outer.CausedBy(e);

    return Result.Fail(outer);
}

Then use it like

public Result SomeBusinessOperation()
{
    var result = Level2();
    return result.IsFailed ? Wrap(result, "Some business operation failed") : result;
}

private Result Level2()
{
    var result = Level1();
    return result.IsFailed ? Wrap(result, "Level 2 operation failed") : result;
}

... 

Use Bind to avoid the pyramid of doom

Instead of nasting multiple if (result.IsFailed) statements, use Bind to chain operations. This keeps the happy path flat and readable.

// Nah... 
var userResult = _service.GetUser(id);
if (userResult.IsSuccess) 
{
    var validation = _validator.Validate(userResult.Value);
    if (validation.IsSuccess)
    {
        // ...
    }
}

// Better...
return _service.GetUser(id)
    .Bind(user => _validator.Validate(user))
    .Bind(user => _repository.Update(user));

Never return null inside a Result

Things like this can drive you crazy, please just don't do it. A Result.Ok<T>(null) is an anti-pattern that leads to NullReferenceException even when IsSuccess is true. If a value might not exist (like in a Find operation), consider return a specific NotFoundError instead of success with a null value.

// Better than returning Ok(null)
public Result<Person> GetPerson(int id) 
{
    var person = _db.Find(id);
    return person ?? Result.Fail(new PersonNotFoundError(id));
}

Avoid overusing the result pattern for private methods

You don't have to use Result<T> for each and every private helper method. If a method is a simple utility that cannot fail in a domain sense, just return the raw type. Prefer to use Result for business logic.

Further reading

GitHub - altmann/FluentResults: A generalised Result object implementation for .NET/C#
A generalised Result object implementation for .NET/C# - altmann/FluentResults
Pyramid of doom (programming) - Wikipedia

Read next