The Result Pattern — Generic Error Handling Without Exceptions

Exceptions in C# are powerful but expensive — throwing and catching an exception allocates heap objects, unwinds the stack, and triggers JIT recompilation of exception handling paths. Using exceptions for expected failures (a user not found, a validation error, a business rule violation) is an anti-pattern — it uses a mechanism designed for exceptional, unexpected errors for routine control flow. The Result pattern models the outcome of an operation as a typed value: either a success containing the result, or a failure containing the error. This is the approach used by functional languages and modern C# domain-driven design.

Implementing Result<T>

// Generic Result type — either a success value or an error
public class Result<T>
{
    public bool    IsSuccess { get; }
    public bool    IsFailure => !IsSuccess;
    public T?      Value     { get; }
    public string? Error     { get; }

    private Result(bool isSuccess, T? value, string? error)
    {
        IsSuccess = isSuccess;
        Value     = value;
        Error     = error;
    }

    // Factory methods
    public static Result<T> Ok(T value)      => new(true,  value, null);
    public static Result<T> Fail(string err) => new(false, default, err);

    // Transform the value if success (Map / Select pattern)
    public Result<TNew> Map<TNew>(Func<T, TNew> transform) =>
        IsSuccess
            ? Result<TNew>.Ok(transform(Value!))
            : Result<TNew>.Fail(Error!);

    // Chain operations that also return Result (Bind / SelectMany)
    public Result<TNew> Bind<TNew>(Func<T, Result<TNew>> next) =>
        IsSuccess ? next(Value!) : Result<TNew>.Fail(Error!);

    public override string ToString() =>
        IsSuccess ? $"Ok({Value})" : $"Fail({Error})";
}

// Non-generic Result for void operations
public class Result
{
    public bool    IsSuccess { get; }
    public bool    IsFailure => !IsSuccess;
    public string? Error     { get; }

    private Result(bool isSuccess, string? error)
        { IsSuccess = isSuccess; Error = error; }

    public static Result Ok()           => new(true,  null);
    public static Result Fail(string e) => new(false, e);
}
Note: The Result pattern makes the error path explicit in the method signature. A method returning Result<Post> is documenting that it can fail in an expected way — callers must handle both the success and failure cases. A method that throws an exception makes the error path invisible in the signature — callers may not know to handle it. This explicit communication aligns with functional programming principles and is core to the CQRS pattern used in the Capstone chapter.
Tip: Use the Result pattern for expected domain failures: validation errors, entity not found, business rule violations, duplicate entries. Keep throwing exceptions for genuinely unexpected errors: null reference, out of memory, IO failure that should crash the application. A practical rule of thumb — if the error is documented in your API specification (400 Bad Request, 404 Not Found, 409 Conflict), model it as a Result. If it would produce a 500 Internal Server Error regardless, let it be an exception.
Warning: The Result pattern adds verbosity — every caller must check IsSuccess. If over-applied to every method, it creates noise. Apply it at service layer boundaries (the methods called by controllers), not deep inside every private helper method. The controller reads the Result and converts it to an IActionResult: if result.IsSuccess return Ok(result.Value), else return the appropriate error response. Private methods inside a service can still throw exceptions internally.

Using Result in a Service and Controller

// ── Service layer — returns Result instead of throwing ────────────────────
public class PostService
{
    private readonly IPostRepository _repo;
    public PostService(IPostRepository repo) => _repo = repo;

    public async Task<Result<Post>> GetByIdAsync(int id)
    {
        var post = await _repo.GetByIdAsync(id);
        return post is null
            ? Result<Post>.Fail($"Post {id} not found")
            : Result<Post>.Ok(post);
    }

    public async Task<Result<Post>> PublishAsync(int id)
    {
        var post = await _repo.GetByIdAsync(id);
        if (post is null) return Result<Post>.Fail($"Post {id} not found");
        if (post.IsPublished) return Result<Post>.Fail("Post is already published");
        if (string.IsNullOrWhiteSpace(post.Title)) return Result<Post>.Fail("Title is required");

        post.IsPublished = true;
        post.PublishedAt = DateTime.UtcNow;
        await _repo.UpdateAsync(post);
        return Result<Post>.Ok(post);
    }
}

// ── Controller — converts Result to IActionResult ─────────────────────────
[ApiController]
[Route("api/posts")]
public class PostsController : ControllerBase
{
    private readonly PostService _service;
    public PostsController(PostService service) => _service = service;

    [HttpGet("{id}")]
    public async Task<IActionResult> GetById(int id)
    {
        var result = await _service.GetByIdAsync(id);
        return result.IsSuccess
            ? Ok(result.Value)
            : NotFound(new { error = result.Error });
    }

    [HttpPost("{id}/publish")]
    public async Task<IActionResult> Publish(int id)
    {
        var result = await _service.PublishAsync(id);
        return result.IsSuccess
            ? Ok(result.Value)
            : result.Error!.Contains("not found")
                ? NotFound(new { error = result.Error })
                : BadRequest(new { error = result.Error });
    }
}

Common Mistakes

Mistake 1 — Accessing Value without checking IsSuccess (NullReferenceException)

❌ Wrong:

var result = await _service.GetByIdAsync(id);
return Ok(result.Value);   // NullReferenceException if IsFailure!

✅ Correct — always check IsSuccess first.

Mistake 2 — Using Result for truly exceptional errors (stack overflow, OOM)

❌ Wrong — wrapping infrastructure exceptions in Result hides real errors.

✅ Correct — Result is for domain failures; let infrastructure exceptions propagate and be caught by global error handling middleware.

🧠 Test Yourself

A service method tries to create a post but the title already exists. Should this be modelled as a Result failure or a thrown exception? Why?