We talk about the Result pattern like it's a better way to handle errors. Return a Result instead of throwing an exception, wrap your failure cases, done. That framing is not wrong, but it undersells what Result actually is.
Result is a flow control tool. Error handling is just a side effect.
The problem with raw returns and exceptions
When a service returns a raw value or throws on failure, the caller owns the failure logic. Every caller. That means domain knowledge — what "not found" means, what "invalid state" means — leaks upward into code that shouldn't care about it.
Exceptions make this worse because they're invisible in the signature. Nothing in this tells you what can go wrong:
Task<User> GetByIdAsync(int id);
You have to read the implementation, or get surprised at runtime.
Result as a communication contract
The shift is treating success and failure as equally valid outcomes, both visible in the signature, both something the caller can reason about without reading the implementation.
public interface IUserService
{
Task<Result<User>> GetByIdAsync(int id);
Task<Result> DeleteAsync(int id);
}
You already know what this service can tell you. You don't need to hunt for thrown exceptions or check for nulls. The contract is in the signature.
The chain
Where Result earns its keep is in composition. Once your services all speak the same language, you can pipeline them:
public async Task<Result> AnonymizeAsync(int userId)
{
return await _userService.GetByIdAsync(userId)
.ThenAsync(user => _anonymizationService.AnonymizeAsync(user))
.ThenAsync(user => _emailService.SendConfirmationAsync(user));
}
Three services. No if/else. No try/catch. If any step fails, the failure propagates, and the rest of the chain doesn't execute. The happy path and the failure path are both readable in the same six lines.
This is the thing most explanations of Result miss. It's not just cleaner error handling. It's a way to express a multi-step flow as a single readable unit.
The controversial claim
Exceptions should be for things you didn't plan for. Infrastructure failures. Bugs. Things that represent a broken assumption about the world.
If/else branching in service code is a smell. It means the caller is making decisions that belong to the callee, or that flow logic is scattered across layers instead of being composed in one place.
Result is the third option. And once you start using it consistently, it should be your default return type for anything that can meaningfully fail.
Failure is composable too
Here's where it gets interesting. Because failure is a first-class value, you can do logic on it.
In a transactional flow, a failure mid-chain isn't just something to report — it's something to act on. You can inspect the error, trigger compensating logic, revert what's already happened, and still return a clean Result to the caller. The failure path is as composable as the success path.
public async Task<Result> ProcessAsync(Order order)
{
var result = await _paymentService.ChargeAsync(order)
.ThenAsync(payment => _inventoryService.ReserveAsync(order));
if (result.IsFailure)
await _paymentService.RefundAsync(order);
return result;
}
No exception handler. No special casing. Just a Result you can inspect and act on like any other value.
The connective tissue
In the previous posts in this series we established what your classes are — Services, Providers, Mutators — and how they relate through DI rather than inheritance. Result is what makes that system actually work at runtime.
Without a shared communication contract, a flat graph of injected services is just a naming convention. With Result, every class in the graph speaks the same language. Flow is explicit. Failure is visible. And the whole thing stays readable without exceptions leaking through layers or if/else logic colonizing your service code.
Top comments (0)