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
Error.UnprocessableContent,Error.NotFound, andError.Conflict - Keep domain code expressive with aggregates, entities, specifications, and domain events
- Integrate with ASP.NET Core using
ToHttpResponse()andAsActionResult<T>()when you are ready to expose APIs
Note
Trellis error codes default to the error kind. For example, new Error.NotFound(ResourceRef.For("Order", orderId)) { Detail = ... } produces the code not-found.
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.Ok(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), new Error.Conflict(null, "conflict") { Detail = "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.Core
dotnet add package Trellis.Primitives
dotnet add package Trellis.Analyzers
The source generator that backs the
Required*<TSelf>base classes is bundled insideTrellis.Core(underanalyzers/dotnet/cs/); no separateTrellis.Core.Generatorpackage is needed.
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
- Use
Result.Ok()for a success-without-payload flow (returnsResult<Unit>;Trellis.Unitis a publicreadonly record structwith the single valueUnit.Default). Error.Equals(...)is value-based for each error case. CompareCodewhen you only need the stable machine-readable category.new Error.NotFound(ResourceRef.For("Order", orderId)) { Detail = ... },new Error.Conflict(null, "conflict") { Detail = ... }, and the other case constructors create specific error subtypes whoseCodedefaults to the hyphenatedKindunless that case exposes a payload-specific code.
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 |