Structural building blocks for reliable .NET enterprise software — Railway-Oriented Programming, Domain-Driven Design primitives, and compiler-enforced correctness.
A trellis guides growth in the right direction. In software, Trellis guides code into correct, composable patterns where errors are handled, domain rules are enforced, and business logic reads like plain English — whether the code is written by a developer or generated by AI.
public Result<User> CreateUser(
string firstName, string lastName, string email)
=> FirstName.TryCreate(firstName)
.Combine(LastName.TryCreate(lastName))
.Combine(EmailAddress.TryCreate(email))
.Bind((first, last, email) => User.TryCreate(first, last, email))
.Ensure(user => !_repository.EmailExists(user.Email), Error.Conflict("Email exists"))
.Tap(user => _repository.Save(user))
.Tap(user => _emailService.SendWelcome(user.Email));This reads as: Create a first name, last name, and email. Combine them to create a user. Ensure the email doesn't already exist. Save the user. Send a welcome email. Any step that fails short-circuits the rest — no nested if-statements, no try-catch, no null checks.
Key properties:
- Open source (MIT) — no vendor lock-in
- Zero runtime overhead — 11-16 ns per operation (0.002% of a typical database call)
- Incremental adoption — install one package into an existing project, no rewrite required
- AI-ready — ships with copilot instructions and a complete API reference that AI coding assistants consume automatically
- .NET 10 / C# 14
- What It Does
- Quick Start
- Core Concepts
- Compiler-Enforced Correctness
- Spec-to-Code Mapping
- Packages
- Performance
- Adoption Path
- AI-Ready Development
- Examples
- Documentation
- Contributing
- License
Trellis combines two proven engineering disciplines — Functional Programming and Domain-Driven Design — into a set of building blocks for .NET enterprise services.
Result<T> makes error handling structural. Every operation returns success (with a value) or failure (with a typed error). The pipeline flows along the success track until something fails, then short-circuits. The compiler won't let you ignore a Result<T> return value.
Strongly-typed value objects eliminate primitive obsession. FirstName, EmailAddress, OrderId — if the object exists, it's valid. The compiler catches parameter mix-ups that string parameters silently allow.
DDD primitives — Aggregate<T>, Entity<T>, Specification<T>, domain events — provide the structural backbone for domain models without reinventing the plumbing.
19 Roslyn analyzers catch incorrect usage at compile time: ignored Result return values, unsafe .Value access, throw inside Result chains, blocking on async Results.
Integration packages bridge Trellis into ASP.NET Core, EF Core, HTTP clients, FluentValidation, state machines, authorization, and CQRS — each mapping Result<T> into the target framework's conventions.
dotnet add package Trellis.Resultsusing Trellis;
// Validate and compose
var result = EmailAddress.TryCreate("user@example.com")
.Ensure(email => !email.Value.EndsWith("@spam.com"),
Error.Validation("Email domain not allowed"))
.Tap(email => Console.WriteLine($"Valid: {email}"));
// Handle success or failure
var message = result.Match(
onSuccess: email => $"Welcome {email}!",
onFailure: error => $"Error: {error.Detail}");
// Async pipelines
var user = await GetUserAsync(userId)
.ToResultAsync(Error.NotFound("User not found"))
.BindAsync(user => SaveUserAsync(user))
.TapAsync(user => SendEmailAsync(user.Email));For ASP.NET Core, add Trellis.Asp to map Result<T> directly to HTTP responses:
dotnet add package Trellis.Asp[HttpPost]
public ActionResult<User> Register([FromBody] RegisterUserRequest request) =>
FirstName.TryCreate(request.FirstName)
.Combine(LastName.TryCreate(request.LastName))
.Combine(EmailAddress.TryCreate(request.Email))
.Bind((first, last, email) => User.TryCreate(first, last, email))
.Ensure(user => !_repository.EmailExists(user.Email), Error.Conflict("Email exists"))
.Tap(user => _repository.Save(user))
.Tap(user => _emailService.SendWelcome(user.Email))
.ToActionResult(this);Quick Start Guide | Full Documentation
Chain operations that flow along the success track or automatically short-circuit on failure.
| Method | Purpose |
|---|---|
| Bind | Transform value inside Result (flatMap) |
| Map | Transform value, preserve Result wrapper |
| Ensure | Add validation — returns failure if predicate is false |
| Tap | Side effects without changing Result |
| Match | Pattern match on success/failure |
| Combine | Combine multiple Results into a tuple |
Domain-level optionality (where T : notnull). Use instead of T? for optional value objects. Supports Map, Match, GetValueOrDefault, TryGetValue, and implicit conversion. Full ASP.NET Core integration: JSON converter, model binder, validation suppression — all registered automatically.
10 discriminated error types that map to HTTP status codes:
| Error | HTTP | Use Case |
|---|---|---|
ValidationError |
400 | Invalid input with field-level details |
NotFoundError |
404 | Entity doesn't exist |
ConflictError |
409 | Duplicate or concurrency violation |
ForbiddenError |
403 | Insufficient permissions |
DomainError |
422 | Business rule violation |
UnexpectedError |
500 | Unhandled failure |
Plus BadRequestError (400), UnauthorizedError (401), RateLimitError (429), ServiceUnavailableError (503).
Prevent primitive obsession with strongly-typed domain objects. If the object exists, it's valid.
// TryCreate — handle errors gracefully (API input, user validation)
var result = Money.TryCreate(amount, currencyCode);
// Create — failure is exceptional (tests, constants, config)
var testMoney = Money.Create(100.00m, "USD");Base types: RequiredString, RequiredGuid, RequiredInt, RequiredDecimal, RequiredEnum. Source generator eliminates boilerplate. 12 ready-to-use value objects: EmailAddress, Url, PhoneNumber, Percentage, CurrencyCode, IpAddress, Hostname, Slug, CountryCode, LanguageCode, Age, Money.
Pattern match on specific error types for precise HTTP responses:
return ProcessOrder(order).MatchError(
onValidation: err => BadRequest(err.FieldErrors),
onNotFound: err => NotFound(err.Detail),
onConflict: err => Conflict(err.Detail),
onSuccess: order => Ok(order));Full async support with Task<Result<T>> and ValueTask<Result<T>> extensions. Parallel execution with automatic result combination:
var result = await Result.ParallelAsync(
() => GetUserAsync(id),
() => GetOrdersAsync(id),
() => GetPreferencesAsync(id))
.WhenAllAsync()
.MapAsync((user, orders, prefs) => new UserProfile(user, orders, prefs));Built-in OpenTelemetry integration for automatic tracing of Result pipelines:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddResultsInstrumentation()
.AddOtlpExporter());Trellis ships 19 Roslyn analyzers that catch incorrect usage at build time. These are the enforcement mechanism — they make it impossible to silently ignore errors or misuse the pipeline.
| Rule | What It Catches |
|---|---|
| Result return value ignored | Discarded error — bug that compiles silently |
Unsafe .Value access |
Accessing a value without checking success first |
throw inside a Result chain |
Defeats structured error handling |
| Blocking on async Result | .Result or .Wait() on Task<Result<T>> |
When a specification says "An Order has an OrderId, a Customer, line items, and a status that transitions from Draft to Submitted to Approved to Shipped" — each term maps directly to a Trellis construct:
| Spec Term | Trellis Construct |
|---|---|
| OrderId | RequiredGuid<OrderId> — typed value object |
| Customer | Aggregate reference via typed ID |
| Order | Aggregate<OrderId> with domain events |
| Status transitions | State machine returning Result<Order> |
| Line items | Collection of Entity<LineItemId> |
| Business rules | Specification<T> — composable expression trees |
This mechanical mapping means domain models are consistent and predictable across services.
| Package | Description | Docs |
|---|---|---|
| Trellis.Results | Result<T>, Maybe<T>, error types, pipeline operators, async extensions |
Docs |
| Trellis.DomainDrivenDesign | Aggregate, Entity, ValueObject, Specification, domain events |
Docs |
| Trellis.Primitives | Base types (RequiredString, RequiredGuid, RequiredInt, RequiredDecimal, RequiredEnum) + 12 ready-to-use value objects |
Docs |
| Trellis.Primitives.Generator | Source generator for value object boilerplate | Docs |
| Trellis.Analyzers | 19 Roslyn analyzers for compile-time safety | Docs |
| Package | Description | Docs |
|---|---|---|
| Trellis.Asp | Result<T> to HTTP responses (MVC and Minimal API), Maybe<T> support |
Docs |
| Trellis.Asp.Authorization | Azure Entra ID v2.0 IActorProvider |
Docs |
| Trellis.Http | HttpClient extensions returning Result<T> with status code handling |
Docs |
| Trellis.FluentValidation | FluentValidation bridge into the Result pipeline | Docs |
| Trellis.Testing | FluentAssertions extensions for Result<T>, test builders |
Docs |
| Trellis.Stateless | State machine integration — transitions return Result<T> |
Docs |
| Trellis.Authorization | Actor, permissions, resource-based auth returning Result<T> |
Docs |
| Trellis.Mediator | CQRS pipeline behaviors for martinothamar/Mediator | Docs |
| Trellis.EntityFrameworkCore | EF Core conventions, value converters, Maybe<T> query extensions |
Docs |
ROP adds 11-16 nanoseconds per operation. Zero extra allocations on Combine. For enterprise workloads doing I/O, this is negligible.
| Operation | Time | Overhead | Memory |
|---|---|---|---|
| Happy Path | 147 ns | 16 ns (12%) | 144 B |
| Error Path | 99 ns | 11 ns (13%) | 184 B |
| Combine (5 results) | 58 ns | - | 0 B |
| Bind chain (5) | 63 ns | - | 0 B |
For context: a single database query is ~1,000,000 ns. ROP overhead is 0.0016% of that.
dotnet run --project Trellis.Benchmark/Trellis.Benchmark.csproj -c ReleaseTrellis does not require a rewrite. Adopt it one endpoint, one method, one service at a time.
- Install
Trellis.Results— start returningResult<T>from new methods - Add
Trellis.Primitives— introduce value objects for new domain concepts - Add
Trellis.Asp— map Results to HTTP responses on new endpoints - Migrate gradually — convert existing endpoints one at a time
Every Trellis type has a clear migration path. No runtime services, no hosted processes, no network dependencies. Thin base classes replaceable with a few lines of manual code. Old and new patterns coexist in the same project.
This repository ships with two files that make AI coding assistants effective immediately:
.github/copilot-instructions.md— coding conventions, naming rules, test patterns, and implementation guidance. AI assistants (GitHub Copilot, Cursor, etc.) consume this automatically.trellis-api-reference.md— complete API reference covering all public types, methods, and extension methods.
When an AI assistant opens a project that uses Trellis, it knows how to build with the framework — correct namespace usage, factory method patterns, async conventions, test organization — without additional prompting.
Async Order Processing
public async Task<IResult> ProcessOrderAsync(int orderId)
{
return await GetOrderAsync(orderId)
.ToResultAsync(Error.NotFound($"Order {orderId} not found"))
.EnsureAsync(
order => order.CanProcessAsync(),
Error.Validation("Order cannot be processed"))
.TapAsync(order => ValidateInventoryAsync(order))
.BindAsync(order => ChargePaymentAsync(order))
.TapAsync(order => SendConfirmationAsync(order))
.MatchAsync(
order => Results.Ok(order),
error => Results.BadRequest(error.Detail));
}Parallel Operations
var result = await Result.ParallelAsync(
() => GetUserAsync(userId),
() => GetOrdersAsync(userId),
() => GetPreferencesAsync(userId))
.WhenAllAsync()
.BindAsync(
(user, orders, preferences) =>
CreateProfileAsync(user, orders, preferences),
ct);HTTP Client Integration
var result = await _httpClient.GetAsync($"api/users/{userId}", ct)
.HandleNotFoundAsync(Error.NotFound("User not found"))
.HandleUnauthorizedAsync(Error.Unauthorized("Please login"))
.HandleServerErrorAsync(code => Error.ServiceUnavailable($"API error: {code}"))
.ReadResultFromJsonAsync(UserContext.Default.User, ct)
.TapAsync(user => _logger.LogInformation("Retrieved user: {UserId}", user.Id));FluentValidation Integration
public class User : Aggregate<UserId>
{
public FirstName FirstName { get; }
public LastName LastName { get; }
public EmailAddress Email { get; }
public static Result<User> TryCreate(FirstName firstName, LastName lastName, EmailAddress email)
{
var user = new User(firstName, lastName, email);
return Validator.ValidateToResult(user);
}
private static readonly InlineValidator<User> Validator = new()
{
v => v.RuleFor(x => x.FirstName).NotNull(),
v => v.RuleFor(x => x.LastName).NotNull(),
v => v.RuleFor(x => x.Email).NotNull(),
};
}Browse all examples | Complete documentation
- Introduction — Core concepts and motivation
- Basics Tutorial — Getting started
- ASP.NET Core Integration
- FluentValidation Integration
- Entity Framework Core Integration
- Clean Architecture / CQRS
- Analyzer Rules
- Debugging Guide
- API Reference
- Changelog
Contributions welcome. Standard GitHub workflow:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
All tests must pass (dotnet test). New features require tests and documentation. For major changes, open an issue first.
MIT License — see LICENSE.
- Documentation
- Discussions — Questions and ideas
- Issues — Bugs and feature requests