Building Chains

A train's route is a chain of junctions. Override Junctions() to define it. .Chain<T>() adds junctions, and the framework handles activation and resolution automatically.

Chain

.Chain<TJunction>() is the primary way to add a junction to a train's route. It resolves the junction, pulls its input from Memory, runs it, and stores the output back in Memory.

protected override Task<Either<Exception, User>> Junctions() =>
        Chain<ValidateEmailJunction>()
        .Chain<CreateUserJunction>()
        .Chain<SendEmailJunction>().Resolve();

For all overloads, type parameter constraints, and junction-wiring behavior, see SDK Reference: Chain. The host's startup chain verification catches missing types before it serves traffic, so a broken chain fails the deploy rather than the first request that takes it.

Railway Behavior

If a previous junction switched the train to the left track, .Chain<TJunction>() is skipped entirely. The exception propagates through the chain and is returned to the caller.

Chain<ValidateEmailJunction>()    // Throws ValidationException
    .Chain<CreateUserJunction>()  // Skipped
    .Chain<SendEmailJunction>();  // Skipped. Caller receives the ValidationException

Resolve

.Resolve() ends the chain and returns Either<Exception, TReturn>:

protected override Task<Either<Exception, User>> Junctions() =>
    Chain<ValidateEmailJunction>()
        .Chain<CreateUserJunction>()
        .Chain<SendEmailJunction>()
        .Resolve();

Resolve checks for a captured exception, then a ShortCircuit value, then looks up TReturn in Memory, in that order. See SDK Reference: Resolve for the full resolution priority and error behavior.

On a train whose return type is already in Memory, because it is the input type or Unit, the chain names no junctions and the whole declaration is Task.FromResult(Resolve()): the train's own Resolve() is synchronous, so it is wrapped to match the Task that Junctions() returns.

There is no overload taking a value. To merge a nested train's result into the output, the junction that calls the nested train returns the merged value, which lands in Memory like any other junction output.

The host catches a missing return type at startup: the chain verification refuses to start when a chain ends without TReturn in Memory. (The Roslyn Analyzer that used to report this as CHAIN002 is deprecated and no longer fires.)

ShortCircuit

.ShortCircuit<TJunction>() lets a junction take the express route, capturing a result for early return. If the junction returns a value of the train's return type, that value is stored as the short-circuit result and Resolve() will return it instead of doing a Memory lookup. If the junction throws, the train continues normally.

Note: Subsequent Chain calls after a successful ShortCircuit still execute. The short-circuit value only affects Resolve(). Nothing in a chain skips the remaining junctions and still returns the short-circuit value. Junctions() cannot branch on its input, because a chain is declared once and checked at startup (Trax.Docs/adr/0016), and a later junction that fails puts the train on the left track, where Resolve() returns that failure rather than the captured value. A junction after a ShortCircuit that should not repeat work has to decide that itself, from what it is given.

public class ProcessOrderTrain : ServiceTrain<OrderRequest, OrderResult>
{
    protected override Task<Either<Exception, OrderResult>> Junctions() =>
        Chain<ValidateOrderJunction>()
            .ShortCircuit<CheckCacheJunction>()  // If cached, capture result for Resolve
            .Chain<CalculatePricingJunction>()   // Still executes (short-circuit only affects Resolve)
            .Chain<ProcessPaymentJunction>()     // Still executes (short-circuit only affects Resolve)
            .Chain<SaveOrderJunction>()
            .Resolve();
}

This behavior is intentionally inverted from Chain. A Chain junction that throws switches the train to the left track with an error. A ShortCircuit junction that throws means "no short-circuit available, keep going." The exception is swallowed, not propagated.

See SDK Reference: ShortCircuit for all overloads, the junction signature, and a full example.

When to use it:

  • Caching: return a cached result if available, otherwise compute it
  • Feature flags: return a default result if a feature is disabled
  • Early exits: skip expensive processing when a precondition is already satisfied

Extract

.Extract<TSource, TTarget>() pulls a nested value out of an object in Memory. It finds the TSource object, looks for a property or field of type TTarget, and stores that value in Memory under the TTarget type.

Chain<LoadUserJunction>()                   // Returns User, stored in Memory
    .Extract<User, EmailAddress>()          // Finds EmailAddress property on User, stores it
    .Chain<ValidateEmailJunction>();         // Takes EmailAddress from Memory

Extract uses reflection to find a property or field on TSource whose type matches TTarget and stores it in Memory. See SDK Reference: Extract for the full search order and failure behavior.

Extract is a convenience for avoiding a junction that exists solely to pull a property off an object. Without it, you'd write:

public class GetUserEmailJunction : Junction<User, EmailAddress>
{
    public override Task<EmailAddress> Run(User input)
        => Task.FromResult(input.Email);
}

.Extract<User, EmailAddress>() does the same thing without the boilerplate. Use it when the property access is trivial. If you need any logic (null checking, transformation, validation), write a junction instead.

AddServices

.AddServices() puts service instances directly into Memory, making them available to subsequent junctions. This bypasses the DI container. The instances you pass are stored as-is.

protected override Task<Either<Exception, User>> Junctions()
{
    var validator = new CustomValidator();
    var notifier = new SlackNotifier();
 
    return AddServices<IValidator, INotifier>(validator, notifier)
        .Chain<ValidateJunction>()     // Can take IValidator from Memory
        .Chain<CreateUserJunction>()
        .Chain<NotifyJunction>()       // Can take INotifier from Memory
        .Resolve();
}

Each type argument is stored in Memory with the corresponding instance. See SDK Reference: AddServices for all overloads and interface-type storage behavior.

Use AddServices when you need to inject runtime-created instances into the chain, like objects that aren't available through the DI container or that need to be created per-execution. For standard dependencies, prefer constructor injection in your junctions instead.

Note: setup that needs async work, a try/catch, or a nested train's result belongs in a junction at the head of the chain, whose return value lands in Memory for the junctions after it.

SDK Reference

Junctions | Chain | ShortCircuit | Extract | AddServices | Resolve