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 ValidationExceptionResolve
.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
Chaincalls after a successfulShortCircuitstill execute. The short-circuit value only affectsResolve(). 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, whereResolve()returns that failure rather than the captured value. A junction after aShortCircuitthat 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
Chainjunction that throws switches the train to the left track with an error. AShortCircuitjunction 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 MemoryExtract 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