Trellis
Structured building blocks for readable, explicit enterprise code
Trellis helps you model your domain with type-safe value objects, compose workflows with Railway-Oriented Programming, and return consistent errors without filling your codebase with null checks and exception plumbing.
Why teams reach for Trellis
The big win is simple: your happy path stays readable even when the real world is messy.
- Validate once, trust everywhere with value objects such as
FirstName,OrderId, andCustomerEmail - Compose workflows safely with
Combine,Bind,Ensure,Tap, andMatch - Return structured errors with concrete types like
ValidationError,NotFoundError, andConflictError - Keep domain code expressive with aggregates, entities, specifications, and domain events
- Integrate with ASP.NET Core using
ToActionResult()andToHttpResult()when you are ready to expose APIs
Note
Trellis error codes end with .error by default. For example, Error.NotFound(...) produces the code not.found.error.
Before and after
The problem: everyday application code often turns into defensive boilerplate.
Traditional approach
var firstName = ValidateFirstName(input.FirstName);
if (firstName is null)
return BadRequest("Invalid first name.");
var lastName = ValidateLastName(input.LastName);
if (lastName is null)
return BadRequest("Invalid last name.");
var email = ValidateEmail(input.Email);
if (email is null)
return BadRequest("Invalid email.");
if (_repository.EmailExists(email))
return Conflict("Email already registered.");
var user = new User(firstName, lastName, email);
_repository.Save(user);
_emailService.SendWelcome(email);
return Ok(user);
With Trellis
using Trellis;
public partial class FirstName : RequiredString<FirstName> { }
public partial class LastName : RequiredString<LastName> { }
public partial class CustomerEmail : RequiredString<CustomerEmail> { }
public sealed record RegisterUserInput(string FirstName, string LastName, string Email);
public sealed record User(FirstName FirstName, LastName LastName, CustomerEmail Email)
{
public static Result<User> TryCreate(FirstName firstName, LastName lastName, CustomerEmail email) =>
Result.Success(new User(firstName, lastName, email));
}
public static Result<User> RegisterUser(
RegisterUserInput input,
Func<CustomerEmail, bool> emailExists,
Action<User> saveUser,
Action<CustomerEmail> sendWelcomeEmail)
{
return FirstName.TryCreate(input.FirstName)
.Combine(LastName.TryCreate(input.LastName))
.Combine(CustomerEmail.TryCreate(input.Email, fieldName: "email"))
.Bind((firstName, lastName, email) => User.TryCreate(firstName, lastName, email))
.Ensure(user => !emailExists(user.Email), Error.Conflict("Email already registered."))
.Tap(saveUser)
.Tap(user => sendWelcomeEmail(user.Email));
}
Same workflow, less ceremony: validate -> create -> check -> save -> notify.
Quick start
Start with the packages most developers need first:
dotnet add package Trellis.Results
dotnet add package Trellis.Primitives
dotnet add package Trellis.Primitives.Generator
dotnet add package Trellis.Analyzers
Then create your first value object and use it in a result flow.
using Trellis;
public partial class OrderNumber : RequiredString<OrderNumber> { }
Result<OrderNumber> orderNumber = OrderNumber.TryCreate("SO-2025-0001");
string message = orderNumber.Match(
onSuccess: value => $"Created order number {value}.",
onFailure: error => $"Validation failed: {error.Detail}"
);
Tip
For quick custom value objects, inherit from the generic base type such as RequiredString<OrderNumber> or RequiredGuid<OrderId>. The generic parameter is required.
What you get
| Capability | Why it matters | Learn more |
|---|---|---|
Result<T> and Maybe<T> |
Make success and failure explicit instead of hiding them in exceptions and nulls | Basics |
| Generated value objects | Turn raw primitives into domain language the compiler understands | Introduction |
| DDD building blocks | Model aggregates, entities, value objects, and specifications directly | Aggregate Factory Pattern |
| Structured error types | Return meaningful failures with default HTTP mappings | Error Handling |
| ASP.NET integration | Convert results to MVC or Minimal API responses without repetitive switch logic | ASP.NET Core Integration |
| Roslyn analyzers | Catch unsafe .Value access and other ROP mistakes during development |
Analyzers |
A practical learning path
If you are new to Trellis, follow this order:
- Introduction - understand the problems Trellis is solving
- Basics - learn the core result operators you will use every day
- Examples - copy real patterns for APIs, async work, and validation
- ASP.NET Core Integration - wire domain results into HTTP endpoints
If you want the full API surface, jump to the API reference after you understand the concepts.
A few accuracy notes worth knowing early
Unitisrecord struct Unit;and has noUnit.Valueproperty. UseResult.Success()for a success-without-payload flow, ornew Unit()/defaultwhen you need aUnitinstance.Error.Equals(...)compares only the error code, not the detail text.Error.NotFound(...),Error.Conflict(...), and the other factory methods create specific error subtypes with default.errorcodes.
Learn more
| Goal | Start here |
|---|---|
| Build your first result pipeline | Basics |
| Understand the mental model | Introduction |
| See working scenarios | Examples |
| Expose APIs | ASP.NET Core Integration |
| Dive into reference material | API Documentation |