Spur-Oriented Programming for .NET
HTTP-first, fluent, AOT-ready error handling for ASP.NET Core with zero core dependencies.
Stop throwing exceptions for business logic failures. Stop writing the same error handling middleware in every project. Start using Spur-Oriented Programming.
// ❌ OLD WAY: Exceptions as control flow
public async Task<UserDto> GetUser(int id)
{
var user = await _repo.FindAsync(id);
if (user == null) throw new NotFoundException("User not found"); // 10-1000x slower
if (!user.IsActive) throw new ValidationException("User inactive");
return _mapper.Map<UserDto>(user);
}
// ✅ NEW WAY: Explicit, type-safe, fast
public async Task<Result<UserDto>> GetUser(int id)
{
return await Result.Start(id)
.ThenAsync(async id => await _repo.FindAsync(id), Error.NotFound("User not found"))
.Validate(user => user.IsActive, Error.Validation("User inactive"))
.Map(user => _mapper.Map<UserDto>(user));
}- 🚀 Zero allocations on success path —
readonly struct Result<T> - 🌐 HTTP-first — Every
Errorcarries an HTTP status code - 🔗 Fluent pipeline —
Then → Map → Validate → Tap → Recover → Match - ⚡ 10-100× faster than exceptions for error paths
- 🎯 Type-safe — Compiler-enforced error handling
- 📦 Zero core dependencies — Spur has no external dependencies
- 🔍 Roslyn analyzers — Catch
Resultmisuse at compile time - 🧪 Test-friendly — Built-in fluent assertions
- 🏗️ Native AOT compatible — via source generators
dotnet add package Spur# For ASP.NET Core Minimal APIs or MVC
dotnet add package Spur.AspNetCore
# For Entity Framework Core
dotnet add package Spur.EntityFrameworkCore
# For FluentValidation
dotnet add package Spur.FluentValidation
# For MediatR (CQRS)
dotnet add package Spur.MediatR
# For unit testing
dotnet add package Spur.Testing
# For Native AOT (optional, enhances AspNetCore)
dotnet add package Spur.Generators
# For compile-time safety checks
dotnet add package Spur.Analyzers| Package | Install When | Dependencies |
|---|---|---|
| Spur | Always (core library) | None ✅ |
| Spur.AspNetCore | Using ASP.NET Core APIs | Microsoft.AspNetCore.App |
| Spur.EntityFrameworkCore | Using EF Core queries | Microsoft.EntityFrameworkCore |
| Spur.FluentValidation | Using FluentValidation | FluentValidation |
| Spur.MediatR | Using MediatR/CQRS | MediatR |
| Spur.Testing | Writing unit tests | None ✅ |
| Spur.Generators | Deploying with Native AOT | Roslyn (build-time) |
| Spur.Analyzers | Want compile-time checks | Roslyn (build-time) |
using Spur;
public Result<int> Divide(int numerator, int denominator)
{
if (denominator == 0)
return Error.Validation("Cannot divide by zero", "DIVISION_BY_ZERO");
return Result.Success(numerator / denominator);
}
// Use it
var result = Divide(10, 2);
if (result.IsSuccess)
Console.WriteLine($"Result: {result.Value}");
else
Console.WriteLine($"Error: {result.Error.Message}");using Spur.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSpur(); // Register Problem Details mapper
var app = builder.Build();
app.MapGet("/users/{id}", async (int id, IUserRepository repo) =>
{
return await repo.GetUserAsync(id)
.ToHttpResult(); // Returns 200 OK or RFC 7807 Problem Details
});
// POST endpoint with validation
app.MapPost("/users", async (CreateUserRequest request,
IValidator<CreateUserRequest> validator,
IUserRepository repo) =>
{
return await Result.Start(request)
.ValidateAsync(validator)
.ThenAsync(async req => await repo.CreateAsync(req))
.ToHttpResult(mapper, successStatusCode: 201);
});Output examples:
Success (200 OK):
{
"id": 1,
"name": "John Doe",
"email": "john@example.com"
}Failure (404 Not Found):
{
"type": "https://api.example.com/errors/USER_NOT_FOUND",
"title": "Not Found",
"status": 404,
"detail": "User with ID 999 not found",
"errorCode": "USER_NOT_FOUND",
"category": "NotFound"
}using Spur.EntityFrameworkCore;
public async Task<Result<User>> GetUserAsync(int id, CancellationToken ct)
{
// FirstOrResultAsync returns Result<User> instead of throwing
return await _db.Users
.Where(u => u.Id == id)
.FirstOrResultAsync(
Error.NotFound($"User {id} not found", "USER_NOT_FOUND"),
ct);
}
public async Task<Result<User>> UpdateUserAsync(User user, CancellationToken ct)
{
_db.Users.Update(user);
// SaveChangesResultAsync catches DbUpdateException → Conflict/Unexpected
return await _db.SaveChangesResultAsync(ct)
.Map(_ => user);
}using Spur.FluentValidation;
public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Age).InclusiveBetween(1, 150);
}
}
public async Task<Result<User>> CreateUserAsync(
CreateUserRequest request,
IValidator<CreateUserRequest> validator,
CancellationToken ct)
{
return await Result.Start(request)
.ValidateAsync(validator, ct) // Automatic validation error → 422
.ThenAsync(async req => await _repo.CreateAsync(req, ct));
}using Spur.MediatR;
public record GetUserQuery(int UserId) : IRequest<Result<UserDto>>;
public class GetUserHandler : ResultHandler<GetUserQuery, UserDto>
{
protected override async Task<Result<UserDto>> HandleAsync(
GetUserQuery request,
CancellationToken ct)
{
return await Result.Start(request.UserId)
.ThenAsync(async id => await _repo.FindAsync(id, ct),
Error.NotFound("User not found"))
.Map(user => _mapper.Map<UserDto>(user));
}
}using Spur.Testing;
[Fact]
public async Task GetUser_WhenExists_ShouldReturnUser()
{
var result = await _service.GetUserAsync(1, CancellationToken.None);
result.ShouldBeSuccess()
.WithValue(user => Assert.Equal("test@example.com", user.Email));
}
[Fact]
public async Task GetUser_WhenNotFound_ShouldReturn404()
{
var result = await _service.GetUserAsync(999, CancellationToken.None);
result.ShouldBeFailure()
.WithCode("USER_NOT_FOUND")
.WithHttpStatus(404)
.WithCategory(ErrorCategory.NotFound);
}dotnet add package Spur
dotnet add package Spur.AspNetCore
dotnet add package Spur.FluentValidation
dotnet add package Spur.EntityFrameworkCoredotnet add package Spur
# That's it! No other dependencies neededdotnet add package Spur
dotnet add package Spur.Testing # For testingdotnet add package Spur
dotnet add package Spur.MediatR
dotnet add package Spur.FluentValidation
dotnet add package Spur.AspNetCore # If exposing HTTP APIdotnet add package Spur
dotnet add package Spur.AspNetCore
dotnet add package Spur.Generators # Enhances AOT compatibility| Operator | Purpose | Example |
|---|---|---|
Then |
Chain operations | result.Then(x => x * 2) |
ThenAsync |
Chain async operations | result.ThenAsync(async x => await GetAsync(x)) |
Map |
Transform success value | result.Map(user => user.Email) |
MapAsync |
Transform async | result.MapAsync(async x => await TransformAsync(x)) |
Validate |
Add validation | result.Validate(x => x > 0, Error.Validation("Must be positive")) |
ValidateAsync |
Async validation | result.ValidateAsync(validator, ct) |
Tap |
Side effects on success | result.Tap(x => _logger.LogInfo($"Value: {x}")) |
TapError |
Side effects on failure | result.TapError(err => _logger.LogError(err.Message)) |
Recover |
Provide fallback | result.Recover(error => defaultValue) |
RecoverIf |
Conditional recovery | result.RecoverIf(ErrorCategory.NotFound, _ => defaultUser) |
Match |
Pattern match result | result.Match(onSuccess: x => x, onFailure: _ => 0) |
// Get value or throw
var value = result.Unwrap();
// Get value or default
var value = result.UnwrapOr(defaultValue);
var value = result.GetValueOrDefault();
// Convert to HTTP response
return result.ToHttpResult(mapper);
// Convert to MVC ActionResult
return result.ToActionResult(mapper);
// Pattern matching
var output = result.Match(
onSuccess: value => $"Success: {value}",
onFailure: error => $"Error: {error.Code}");// Built-in error factories
Error.Validation("Invalid input", "VALIDATION_ERROR"); // 422
Error.NotFound("Resource not found", "NOT_FOUND"); // 404
Error.Unauthorized("Access denied", "UNAUTHORIZED"); // 401
Error.Forbidden("Forbidden", "FORBIDDEN"); // 403
Error.Conflict("Already exists", "CONFLICT"); // 409
Error.TooManyRequests("Rate limit exceeded", "RATE_LIMIT"); // 429
Error.Unexpected("System error", "UNEXPECTED_ERROR"); // 500
// Custom error with custom status code
Error.Custom(418, "I_AM_A_TEAPOT", "I'm a teapot", ErrorCategory.Custom);
// With extensions (additional metadata)
Error.Validation("Email is invalid")
.WithExtensions(new { Field = "Email", Regex = @"^\S+@\S+$" });
// With inner error
Error.Unexpected("Database error")
.WithInner(Error.Conflict("Unique constraint violation"));// Program.cs
builder.Services.AddSpur(options =>
{
// RFC 7807 Problem Details type URL prefix
options.ProblemDetailsTypeBaseUri = "https://api.myapp.com/errors/";
// Include error extensions in Problem Details response
options.IncludeExtensions = true;
// Include inner error details
options.IncludeInnerErrors = true;
// Custom status code mapping (optional)
options.CustomStatusMapper = error => error.Category switch
{
ErrorCategory.Custom => error.HttpStatus,
_ => null // Use default
};
});Spur is designed for zero-allocation success paths:
| Operation | Allocations | Speed vs Exception |
|---|---|---|
Result.Success(value) |
0 bytes | N/A |
Result.Failure(error) |
0 bytes | N/A |
| 3-step pipeline (success) | 0 bytes | N/A |
Result failure path |
Minimal | 10-100× faster |
Run benchmarks:
dotnet run -c Release --project benchmarks/Spur.BenchmarksSpur includes analyzers that catch common mistakes:
| Rule | Description |
|---|---|
| RF0001 | Result value is ignored (must be used or stored) |
| RF0002 | Unsafe access to Result.Value without checking IsSuccess |
| RF0003 | Unsafe access to Result.Error without checking IsFailure |
Add to GlobalUsings.cs:
global using Spur;
global using Spur.Pipeline;
// Add only the packages you use:
global using Spur.AspNetCore;
global using Spur.EntityFrameworkCore;
global using Spur.FluentValidation;
// In test projects only:
global using Spur.Testing;- .NET 10.0 (primary)
- .NET 9.0
- .NET 8.0
See the complete sample application for a working CRUD API demonstrating all features.
cd samples/Spur.SampleApi
dotnet run
# API available at http://localhost:5000Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
This project is licensed under the MIT License - see the LICENSE file for details.
Spur is inspired by Spur-Oriented Programming concepts from functional programming languages (F#, Rust, Haskell) and brings them idiomatically to .NET.