Skip to content

xavierjohn/Trellis

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

286 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Trellis

Build codecov NuGet NuGet Downloads License: MIT .NET C# GitHub Stars Documentation

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

Table of Contents

What It Does

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 primitivesAggregate<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.


Quick Start

dotnet add package Trellis.Results
using 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


Core Concepts

Result<T> Pipeline

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

Maybe<T>

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.

Error Types

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).

Type-Safe Value Objects

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.

Discriminated Error Matching

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));

Async and Parallel Operations

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));

Distributed Tracing

Built-in OpenTelemetry integration for automatic tracing of Result pipelines:

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddResultsInstrumentation()
        .AddOtlpExporter());

Compiler-Enforced Correctness

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>>

Full analyzer documentation


Spec-to-Code Mapping

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.


Packages

Core

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

Integration

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

Performance

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.

Detailed benchmarks

dotnet run --project Trellis.Benchmark/Trellis.Benchmark.csproj -c Release

Adoption Path

Trellis does not require a rewrite. Adopt it one endpoint, one method, one service at a time.

  1. Install Trellis.Results — start returning Result<T> from new methods
  2. Add Trellis.Primitives — introduce value objects for new domain concepts
  3. Add Trellis.Asp — map Results to HTTP responses on new endpoints
  4. Migrate gradually — convert existing endpoints one at a time

No lock-in

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.


AI-Ready Development

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.


Examples

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


Documentation

Complete Documentation Site


Contributing

Contributions welcome. Standard GitHub workflow:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Commit changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

All tests must pass (dotnet test). New features require tests and documentation. For major changes, open an issue first.


License

MIT License — see LICENSE.


Community


About

Functional programming with Domain Driven Design.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Languages