Skip to main content

Result-Oriented Programming

Result-Oriented Programming (ROP) is a functional pattern that treats your application as a railway with two tracks: a success track and a failure track. Spur makes this pattern natural in C#.

The railway metaphor

Imagine each operation in your code as a segment of railway track. Every segment has two possible outputs:

  • Success — the train continues forward on the top track.
  • Failure — the train switches to the bottom (error) track and skips all remaining operations.
  Success ──→ Then ──→ Validate ──→ Map ──→ ✅ Value
↘ ↘ ↘
Failure ──────────────────────────────→ ❌ Error

Once on the failure track, the train stays there — no more processing happens. This is called short-circuiting.

Why not exceptions?

ExceptionsResult<T>
VisibilityHidden — not in the method signatureExplicit — Result<T> in the return type
Performance~1 000× slower (stack unwinding)Zero-cost on success, 10–100× faster on failure
Compiler helpNone — forgotten catch is invisibleCompiler enforces handling
Control flowNon-local jumpsLinear, predictable
HTTP mappingManual middlewareBuilt-in status codes

A practical example

Traditional approach with exceptions:

public async Task<OrderDto> PlaceOrder(PlaceOrderRequest request)
{
var user = await _userRepo.GetByIdAsync(request.UserId)
?? throw new NotFoundException("User not found");

if (!user.IsActive)
throw new ValidationException("User is inactive");

var product = await _productRepo.GetByIdAsync(request.ProductId)
?? throw new NotFoundException("Product not found");

if (product.Stock < request.Quantity)
throw new ValidationException("Insufficient stock");

var order = new Order(user, product, request.Quantity);
await _orderRepo.SaveAsync(order);

return order.ToDto();
}

The same logic with Spur:

public async Task<Result<OrderDto>> PlaceOrder(PlaceOrderRequest request)
{
return await Result.Start(request)
.ThenAsync(r => _userRepo.GetByIdAsync(r.UserId))
.Validate(user => user.IsActive,
Error.Validation("User is inactive", "USER_INACTIVE"))
.ThenAsync(_ => _productRepo.GetByIdAsync(request.ProductId))
.Validate(product => product.Stock >= request.Quantity,
Error.Validation("Insufficient stock", "INSUFFICIENT_STOCK"))
.MapAsync(product => CreateAndSaveOrder(request, product))
.Map(order => order.ToDto());
}

Benefits:

  • Every step that can fail returns Result<T>.
  • If the user is not found, the rest of the pipeline is skipped.
  • The compiler forces callers to handle both success and failure.
  • No exception overhead on any failure path.

Building blocks

Spur provides six pipeline operators — each one does exactly one job:

OperatorPurposeChanges value?Can fail?
ThenChain a fallible operation✅ Yes✅ Yes
MapTransform the success value✅ Yes❌ No
ValidateAssert a condition❌ No✅ Yes
TapSide effect (logging, caching)❌ No❌ No
RecoverHandle an error, get back on track✅ YesMaybe
MatchTerminal — branch on success/failure✅ Yes❌ No

Composing pipelines

Small, focused functions compose into readable pipelines:

public async Task<Result<UserDto>> RegisterUser(RegisterRequest request)
{
return await Result.Start(request)
.Validate(r => !string.IsNullOrWhiteSpace(r.Email),
Error.Validation("Email is required", "EMAIL_REQUIRED"))
.ThenAsync(r => _userRepo.EnsureEmailNotTaken(r.Email))
.ThenAsync(r => _userRepo.CreateAsync(r))
.TapAsync(user => _emailService.SendWelcomeAsync(user.Email))
.Map(user => user.ToDto());
}

Each line is a self-contained step. Reading top to bottom tells you exactly what the operation does and where it can fail.

When to use Result<T>

Use Result<T> for expected, business-level failures:

  • User not found
  • Validation errors
  • Duplicate records
  • Permission denied
  • Rate limiting

Keep throwing exceptions for truly unexpected failures:

  • Null reference bugs
  • Out-of-memory
  • Network timeouts (unless you want to handle them as Results)

Next steps

  • Then — chaining fallible operations
  • Map — transforming success values
  • Validate — asserting conditions
  • Tap — side effects
  • Recover — error recovery
  • Match — terminal branching