Spur 1.0.0
dotnet add package Spur --version 1.0.0
NuGet\Install-Package Spur -Version 1.0.0
<PackageReference Include="Spur" Version="1.0.0" />
<PackageVersion Include="Spur" Version="1.0.0" />
<PackageReference Include="Spur" />
paket add Spur --version 1.0.0
#r "nuget: Spur, 1.0.0"
#:package Spur@1.0.0
#addin nuget:?package=Spur&version=1.0.0
#tool nuget:?package=Spur&version=1.0.0
Spur
Spur-Oriented Programming for .NET
HTTP-first, fluent, AOT-ready error handling for ASP.NET Core with zero core dependencies.
Why Spur?
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));
}
Features
- ๐ 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
Installation
Core Library (Required)
dotnet add package Spur
Choose Your Integrations
# 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 Guide
| 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) |
Quick Start
1. Basic Error Handling
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}");
2. ASP.NET Core Minimal API
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"
}
3. Entity Framework Core Integration
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);
}
4. FluentValidation Integration
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));
}
5. MediatR/CQRS Integration
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));
}
}
6. Unit Testing
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);
}
Use Cases & Package Selection
Scenario 1: ASP.NET Core Web API
dotnet add package Spur
dotnet add package Spur.AspNetCore
dotnet add package Spur.FluentValidation
dotnet add package Spur.EntityFrameworkCore
Scenario 2: Console Application / Business Logic Library
dotnet add package Spur
# That's it! No other dependencies needed
Scenario 3: Blazor/MAUI Client
dotnet add package Spur
dotnet add package Spur.Testing # For testing
Scenario 4: CQRS Application with MediatR
dotnet add package Spur
dotnet add package Spur.MediatR
dotnet add package Spur.FluentValidation
dotnet add package Spur.AspNetCore # If exposing HTTP API
Scenario 5: Native AOT Deployment
dotnet add package Spur
dotnet add package Spur.AspNetCore
dotnet add package Spur.Generators # Enhances AOT compatibility
Pipeline Operators
Core Operators
| 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) |
Terminal Operations
// 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}");
Error Types
// 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"));
Configuration
ASP.NET Core Setup
// 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
};
});
Performance
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.Benchmarks
Roslyn Analyzers
Spur 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 |
Global Usings (Recommended)
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;
Target Frameworks
- .NET 10.0 (primary)
- .NET 9.0
- .NET 8.0
Sample Application
See the complete sample application for a working CRUD API demonstrating all features.
cd samples/Spur.SampleApi
dotnet run
# API available at http://localhost:5000
Documentation
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Acknowledgements
Spur is inspired by Spur-Oriented Programming concepts from functional programming languages (F#, Rust, Haskell) and brings them idiomatically to .NET.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages (5)
Showing the top 5 NuGet packages that depend on Spur:
| Package | Downloads |
|---|---|
|
Spur.Testing
Test assertion helpers for Spur. Fluent assertion API for testing Result types. Framework-agnostic, works with xUnit, NUnit, MSTest. Zero external dependencies. |
|
|
Spur.EntityFrameworkCore
Entity Framework Core integration for Spur. Query extensions that return Result types instead of throwing. FirstOrResultAsync, SingleOrResultAsync, SaveChangesResultAsync with automatic exception handling. |
|
|
Spur.AspNetCore
ASP.NET Core integration for Spur. Provides IResult and IActionResult extensions with RFC 7807 Problem Details support. One-line conversion from Result to HTTP responses for Minimal APIs and MVC controllers. |
|
|
Spur.MediatR
MediatR integration for Spur. Base handler class and pipeline behavior for CQRS patterns. Automatic exception wrapping and Result-based handlers. |
|
|
Spur.FluentValidation
FluentValidation integration for Spur. Seamlessly integrate FluentValidation validators into Result pipelines. Automatic conversion of validation failures to Result errors. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 146 | 2/23/2026 |
See CHANGELOG.md for details