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);
}
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.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.