Basics
This article teaches the handful of Trellis concepts you will use most often: value objects, Result<T>, and the core operators that turn a multi-step workflow into readable code.
Table of Contents
- What problem does Railway-Oriented Programming solve?
- Why avoid primitive obsession?
- Meet
Result<T> - Core operations
- Putting it together
- Working with async operations
- Common beginner questions
- Quick reference
- Next steps
What problem does Railway-Oriented Programming solve?
The answer is: it keeps the happy path readable even when every step can fail.
Without ROP, each validation or database check forces another if, another return, or another exception path. With Trellis, a failure automatically moves the workflow onto the failure track and the remaining success steps are skipped.
graph LR
A[Start] --> B{Validate input}
B -->|Success| C{Load data}
B -->|Failure| F[Failure result]
C -->|Success| D{Apply rule}
C -->|Failure| F
D -->|Success| E[Success result]
D -->|Failure| F
Before and after
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);
return Ok(user);
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)
{
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."));
}
The second version reads like the business process instead of the defensive scaffolding around it.
Why avoid primitive obsession?
The problem with primitives is not that they are simple. The problem is that they erase intent.
public sealed record Person(string FirstName, string LastName);
Person CreatePerson(string firstName, string lastName) => new(firstName, lastName);
var person = CreatePerson("Smith", "Jane");
That compiles even if the arguments are in the wrong order.
Replace raw primitives with value objects
using Trellis;
[StringLength(100)]
public partial class FirstName : RequiredString<FirstName> { }
[StringLength(100)]
public partial class LastName : RequiredString<LastName> { }
public sealed record Person(FirstName FirstName, LastName LastName);
Person CreatePerson(FirstName firstName, LastName lastName) => new(firstName, lastName);
Now invalid strings are rejected when you create the value object, and parameter mix-ups become compiler errors.
Result<FirstName> firstName = FirstName.TryCreate("Jane");
Result<LastName> lastName = LastName.TryCreate("Smith");
Tip
If you need domain-specific rules beyond required text, implement the optional ValidateAdditional(...) partial method described in the primitives API reference.
Meet Result<T>
The answer to "how do I represent success or failure explicitly?" is Result<T>.
A Result<T> contains either:
- a successful value, or
- a failed error
It never contains both.
Result<FirstName> result = FirstName.TryCreate("Jane");
Safe ways to consume a result
Option 1: Match
string message = result.Match(
onSuccess: name => $"Hello, {name}.",
onFailure: error => $"Validation failed: {error.Detail}"
);
Option 2: TryGetValue
if (result.TryGetValue(out var name))
Console.WriteLine(name);
else if (result.TryGetError(out var error))
Console.WriteLine(error.Detail);
Option 3: explicit state checks
if (result.IsSuccess)
Console.WriteLine(result.Value);
else
Console.WriteLine(result.Error.Detail);
Warning
Value throws when the result is a failure, and Error throws when the result is a success. Use Match, TryGetValue, or explicit state checks unless you are certain which track you are on.
Core operations
Each operator solves a different problem. Once you learn these, most Trellis pipelines become easy to read.
Combine: validate independent inputs together
Use Combine when the inputs do not depend on each other and you want to keep all validation failures.
var result = FirstName.TryCreate("Jane")
.Combine(LastName.TryCreate("Smith"))
.Combine(CustomerEmail.TryCreate("jane@example.com", fieldName: "email"));
Why it matters: form-style input usually has multiple invalid fields at once. Combine lets you surface them together instead of stopping at the first problem.
Bind: call the next result-producing step
Use Bind when the next step already returns Result<T>.
public static Result<Person> CreatePerson(FirstName firstName, LastName lastName) =>
Result.Success(new Person(firstName, lastName));
var result = FirstName.TryCreate("Jane")
.Combine(LastName.TryCreate("Smith"))
.Bind((firstName, lastName) => CreatePerson(firstName, lastName));
Rule of thumb: if your lambda returns a Result, you almost always want Bind.
Map: transform a successful value
Use Map when the transformation itself cannot fail.
var result = FirstName.TryCreate("Jane")
.Map(name => name.Value.ToUpperInvariant());
Map changes the success value but leaves failures alone.
Ensure: add a business rule
Use Ensure when the value is structurally valid, but you still need a domain rule.
var result = CustomerEmail.TryCreate("jane@example.com", fieldName: "email")
.Ensure(email => !email.Value.EndsWith("@blocked.example", StringComparison.OrdinalIgnoreCase),
Error.Validation("Blocked email domains are not allowed.", "email"));
A good mental model is:
TryCreatechecks shape and basic validityEnsurechecks context-specific business rules
Tap: run a side effect without changing the result
Use Tap when you want to log, save, publish, or notify on the success path.
var saved = false;
var result = FirstName.TryCreate("Jane")
.Tap(_ => saved = true);
The result still contains the original FirstName. Tap is for side effects, not transformations.
EnsureAll: collect several business-rule failures at once
Use EnsureAll when showing all rule violations is better than stopping at the first one.
public sealed record CheckoutRequest(string CouponCode, decimal Subtotal, string Currency);
var result = Result.Success(new CheckoutRequest("SPRING25", 125m, "USD"))
.EnsureAll(
(request => request.Subtotal > 0m, Error.Validation("Subtotal must be greater than zero.", "subtotal")),
(request => request.Currency.Length == 3, Error.Validation("Currency must be a 3-letter code.", "currency")),
(request => request.CouponCode.Length <= 20, Error.Validation("Coupon code is too long.", "couponCode")));
RecoverOnFailure: provide a fallback path
Use RecoverOnFailure when a failure should trigger another attempt.
public sealed record CustomerProfile(string Source);
Result<CustomerProfile> fromCache = Error.NotFound("Customer not found in cache.");
Result<CustomerProfile> fromDatabase = Result.Success(new CustomerProfile("database"));
Result<CustomerProfile> result = fromCache.RecoverOnFailure(
predicate: error => error is NotFoundError,
func: _ => fromDatabase);
Match: finish the pipeline
Use Match at the edge of your workflow when you need a plain value.
string response = RegisterUser(new RegisterUserInput("Jane", "Smith", "jane@example.com"), _ => false)
.Match(
onSuccess: user => $"Registered {user.Email}.",
onFailure: error => $"Registration failed: {error.Detail}"
);
Putting it together
Here is a complete example using the core operators in one flow.
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));
}
Read that pipeline left to right:
- validate the incoming fields
- create the domain object
- enforce the duplicate-email rule
- save the user
- send the welcome email
That is the everyday Trellis experience.
Working with async operations
The async story is the same mental model: keep the workflow readable while I/O happens in the background.
Simple async chain
using Trellis;
public sealed class Customer
{
public Customer(string email, bool canBePromoted)
{
Email = email;
CanBePromoted = canBePromoted;
}
public string Email { get; }
public bool CanBePromoted { get; }
public Task PromoteAsync() => Task.CompletedTask;
}
public static Task<Customer?> GetCustomerByIdAsync(long id) =>
Task.FromResult(id == 1 ? new Customer("customer@example.com", true) : null);
public static Task<Result<Unit>> SendPromotionNotificationAsync(string email) =>
Task.FromResult(Result.Success(new Unit()));
string message = await GetCustomerByIdAsync(1)
.ToResultAsync(Error.NotFound("Customer not found."))
.EnsureAsync(customer => customer.CanBePromoted, Error.Validation("Customer cannot be promoted."))
.TapAsync(customer => customer.PromoteAsync())
.BindAsync(customer => SendPromotionNotificationAsync(customer.Email))
.MatchAsync(
onSuccess: _ => "Promotion completed.",
onFailure: error => error.Detail);
Note
Unit has no Value property. When you need a successful Result<Unit>, use Result.Success() or create a Unit with new Unit() / default.
Parallel async work
When several calls are independent, run them in parallel and combine the results afterward.
using Trellis;
public sealed record Dashboard(string Profile, string Orders, string Preferences);
static Task<Result<string>> FetchUserProfileAsync(string userId) =>
Task.FromResult(Result.Success($"Profile for {userId}"));
static Task<Result<string>> FetchUserOrdersAsync(string userId) =>
Task.FromResult(Result.Success($"Orders for {userId}"));
static Task<Result<string>> FetchUserPreferencesAsync(string userId) =>
Task.FromResult(Result.Success($"Preferences for {userId}"));
Result<Dashboard> dashboard = await Result.ParallelAsync(
() => FetchUserProfileAsync("user-123"),
() => FetchUserOrdersAsync("user-123"),
() => FetchUserPreferencesAsync("user-123"))
.WhenAllAsync()
.MapAsync((profile, orders, preferences) =>
new Dashboard(profile, orders, preferences));
Use this when the operations do not depend on each other and latency matters.
Common beginner questions
When should I use Bind instead of Map?
Use Bind when your function returns Result<T>.
using Trellis;
public sealed record Person(long Id, string Name);
Result<Person> LoadPerson(long id) => Result.Success(new Person(id, "Jane"));
Use Map when your function returns a plain value.
string FormatName(Person person) => person.Name.ToUpperInvariant();
How do I handle different error types?
Use MatchError when the response should depend on the concrete error subtype.
using Microsoft.AspNetCore.Http;
using Trellis;
Result<string> result = Error.NotFound("Order not found.");
IResult httpResult = result.MatchError(
onSuccess: value => Results.Ok(value),
onValidation: error => Results.BadRequest(error.FieldErrors),
onNotFound: error => Results.NotFound(error.Detail),
onConflict: error => Results.Conflict(error.Detail),
onError: error => Results.StatusCode(StatusCodes.Status500InternalServerError));
What if I need to inspect failures in the middle of a chain?
Use TapOnFailure.
Result<string> result = Error.Unexpected("Email service offline.")
.TapOnFailure(error => Console.WriteLine($"Failure: {error.Code}"));
Quick reference
Choosing an operator
| If you need to... | Use... |
|---|---|
| validate several independent inputs | Combine |
| call the next result-producing operation | Bind |
| transform a success value | Map |
| add a business rule | Ensure |
| collect several rule failures | EnsureAll |
| run a side effect on success | Tap |
| run a side effect on failure | TapOnFailure |
| recover from a failure | RecoverOnFailure |
| finish the chain | Match or MatchError |
Cheat sheet
flowchart TD
START{What do you need?}
START -->|Validate multiple inputs| COMBINE[Combine]
START -->|Call another Result-returning method| BIND[Bind]
START -->|Transform a success value| MAP[Map]
START -->|Add a rule| ENSURE[Ensure]
START -->|Run side effects| TAP[Tap]
START -->|Recover from failure| RECOVER[RecoverOnFailure]
START -->|Turn the result into a response| MATCH[Match]
Creating value objects
using Trellis;
public partial class OrderNumber : RequiredString<OrderNumber> { }
public partial class OrderId : RequiredGuid<OrderId> { }
Result<OrderNumber> orderNumber = OrderNumber.TryCreate("SO-2025-0001");
OrderId newId = OrderId.NewUniqueV7();
Next steps
- Read Examples for end-to-end scenarios
- Read Introduction if you want the bigger picture again
- Read ASP.NET Core Integration when you are ready to map results to HTTP
- Keep the API reference nearby for exact signatures