How-to guide

Organize trains in a project

Give each train a folder. Its top level holds the train's contract and nothing else: the input, the output, the interface and the class. The junctions go in Junctions/, and every other type the train uses goes in Models/:

Trains/
├── CreateUser/
│   ├── CreateUserRequest.cs        # input
│   ├── ICreateUserTrain.cs         # interface
│   ├── CreateUserTrain.cs          # train
│   └── Junctions/
│       ├── ValidateEmailJunction.cs
│       └── CreateUserInDatabaseJunction.cs
└── ProcessOrder/
    ├── ProcessOrderRequest.cs      # input
    ├── ProcessOrderResult.cs       # output
    ├── IProcessOrderTrain.cs
    ├── ProcessOrderTrain.cs
    ├── Junctions/
    │   ├── PriceOrderJunction.cs
    │   └── ChargePaymentJunction.cs
    └── Models/
        ├── OrderLine.cs            # part of the input: public
        └── PricedOrder.cs          # passed between junctions: internal

1. Give the train its own input type

namespace YourApp.Trains.CreateUser;
 
public record CreateUserRequest
{
    public required string Email { get; init; }
    public required string FirstName { get; init; }
    public required string LastName { get; init; }
}

ITrainBus.RunAsync(input) finds the train by the input's type. Two trains may share an input type, but the bus then reaches only the first one it scanned, and the other can be run only by name. A type per train keeps every train reachable by input.

2. Declare an interface for the train

using Trax.Effect.Services.ServiceTrain;
 
namespace YourApp.Trains.CreateUser;
 
public interface ICreateUserTrain : IServiceTrain<CreateUserRequest, User>;

AddMediator registers the train under this interface, callers inject it, and its full name (YourApp.Trains.CreateUser.ICreateUserTrain) is the name runs are recorded under.

3. Put the junctions in a Junctions folder, internal

using System.ComponentModel.DataAnnotations;
using Trax.Core.Functional;
using Trax.Effect.Services.EffectJunction;
 
namespace YourApp.Trains.CreateUser.Junctions;
 
internal class ValidateEmailJunction(IUserRepository users)
    : EffectJunction<CreateUserRequest, Unit>
{
    public override async Task<Unit> Run(CreateUserRequest input)
    {
        if (await users.GetByEmailAsync(input.Email) is not null)
            throw new ValidationException($"Email {input.Email} already exists");
 
        return Unit.Default;
    }
}
 
internal class CreateUserInDatabaseJunction(IUserRepository users)
    : EffectJunction<CreateUserRequest, User>
{
    public override Task<User> Run(CreateUserRequest input) =>
        users.CreateAsync(new User(input.Email, $"{input.FirstName} {input.LastName}"));
}

The rest of the application talks to ICreateUserTrain, so the junctions stay internal. Take dependencies through the constructor. Do not put [Inject] properties on a junction: a junction the chain builds with Chain<T>() is built from its constructor and its properties are never filled (the startup chain check warns about each one). [Inject] is for the train's own properties.

4. Write the train

using Trax.Core.Functional;
using Trax.Effect.Services.ServiceTrain;
using YourApp.Trains.CreateUser.Junctions;
 
namespace YourApp.Trains.CreateUser;
 
public class CreateUserTrain : ServiceTrain<CreateUserRequest, User>, ICreateUserTrain
{
    protected override Task<Either<Exception, User>> Junctions() =>
        Chain<ValidateEmailJunction>().Chain<CreateUserInDatabaseJunction>().Resolve();
}

5. Put the other types in Models, internal unless a caller needs them

A type the input or output is made of is part of the contract, so callers build or read it. It goes in Models/ and stays public:

namespace YourApp.Trains.ProcessOrder.Models;
 
public record OrderLine(string Sku, int Quantity);

A type only the junctions hand each other, through Memory, is part of how the train works. It goes in Models/ too, and is internal, like the junctions that use it:

namespace YourApp.Trains.ProcessOrder.Models;
 
internal record PricedOrder(decimal Total);

Memory finds an internal type the same way it finds a public one, and the startup chain check checks it the same way. Keeping these types internal means the compiler, not the folder, stops the rest of the application from depending on a train's insides. A type that more than one train uses belongs to neither: give it a folder of its own outside Trains/.

6. Share a junction only when it is generic

When several trains need the same junction, put it in Trains/Shared/Junctions/:

Trains/
├── Shared/
│   └── Junctions/
│       └── ValidateEmailFormatJunction.cs
├── CreateUser/
└── UpdateUser/

Share late. A shared junction that grows conditionals for the trains that use it should be split back into one junction per train.

SDK Reference

ServiceTrain | EffectJunction | Chain | AddMediator | TrainBus