Table of Contents

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?

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:

  • TryCreate checks shape and basic validity
  • Ensure checks 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:

  1. validate the incoming fields
  2. create the domain object
  3. enforce the duplicate-email rule
  4. save the user
  5. 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