MIT licensed. .NET 10. Runs inside your ASP.NET app.

Business logic you can call, schedule, or serve as an API

A train is a typed pipeline of small steps. Call it from a controller, put it on a cron schedule, send it to a worker on another machine, or publish it as a GraphQL mutation.

It is the same class each time, and every run is written to your Postgres: when it started, how it ended, and which step failed.

$ dotnet add package Trax.Core

  Input
    │
    ▼
  ┌─ CheckInventory ─┐
  │                   │
  ├─ ChargePayment ──┤
  │                   │
  ├─ CreateShipment ─┤
  │                   │
  ▼                   ▼
  Output             Exception

  success             failure

Each step does one thing. Trax handles the space between them.

A junction takes a typed input and returns a typed output. When one throws, the rest are skipped and the train arrives carrying the exception, so the route reads top to bottom. Written by hand, every call needs its own check, and the order of operations gets lost in the error handling.

Each junction's output is stored in Memory by type, and the next junction declares what it needs. You never pass values between steps by hand.

The rest of Trax is built on this structure. Because every step is declared before the train runs, the host checks the whole chain at startup, the run record names the exact junction that failed, and the train can be published as a typed API without a controller.

ProcessOrderTrain.cs
protected override Task<Either<Exception, OrderReceipt>> Junctions() =>
    Chain<CheckInventoryJunction>()
        .Chain<ChargePaymentJunction>()
        .Chain<CreateShipmentJunction>()
        .Resolve();
OrderService.csthe same route without Trax
var inventory = await _inventory.CheckAsync(request.Items);
if (!inventory.Available)
    return Error("Items out of stock");

var payment = await _payments.ChargeAsync(request.Payment, request.Total);
if (!payment.Success)
    return Error("Payment failed");

var shipment = await _shipping.CreateAsync(request.Address, request.Items);
if (shipment == null)
    return Error("Shipping setup failed");

return new OrderReceipt(payment, shipment);

See it running

Each sample is a complete app you can run in a few minutes. Every tab plays recordings of the real sample in your browser, so you can try it here, then start the same C# yourself.

A run that recovers without asking the model again

A train asks a decision model which track to take, a later step crashes, and the manifest's retry takes the same tracks by replaying the recorded answers. Every junction, question and track reaches the page as a junction event.

  • •Switch, Scale and Gate tracks
  • •Retries that replay recorded decisions
  • •Junction events over a GraphQL subscription
Scenario
Actions

Progress
  1. Runs
  2. Breaks
  3. Recovers
Code Trains/Research/ResearchTopicTrain.cs
18protected override Task<Either<Exception, ResearchReport>> Junctions() =>
19 Chain<PlanResearch>()
20 .Switch<ResearchBrief, Source>(tracks =>
21 tracks
22 .When(Source.Web, t => t.Chain<SearchWeb>())
23 .When(Source.Papers, t => t.Chain<SearchPapers>())
24 .When(Source.Wiki, t => t.Chain<SearchWiki>())
25 )
26 .Scale<Findings, Depth>(scale =>
27 scale
28 .AtLeast(Depth.Skim, t => t.Chain<SkimSources>())
29 .AtLeast(Depth.CrossCheck, t => t.Chain<FetchFullTexts>())
30 )
31 .Chain<Summarize>()
32 .Resolve();
Console

Pick a scenario and press Run.

Timeline

Each attempt's junctions, questions and tracks appear here as they happen.

Recorded from the Recovery sample running against Postgres, and played back in your browser: every junction, answer and track arrived as a junction event. The model is the sample's stand-in, which answers like a typed decision model; one setting points it at Nimble instead.

The real C#

Run it yourself

$ git clone https://github.com/TraxSharp/Trax.git && cd Trax/Trax.Samples
$ docker compose up -d database
$ dotnet run --project samples/Recovery/Trax.Samples.Recovery.Api
$ cd samples/Recovery/Trax.Samples.Recovery.Client && npm ci && npm run dev

One train, four ways to run it

This train recalculates a game's leaderboard. It is adapted from the game server sample, and nothing in it knows how it will be run.

public class RecalculateLeaderboardTrain
    : ServiceTrain<RecalculateLeaderboardInput, RecalculateLeaderboardOutput>,
        IRecalculateLeaderboardTrain
{
    protected override Task<Either<Exception, RecalculateLeaderboardOutput>> Junctions() =>
        Chain<AggregateScoresJunction>()
            .Chain<RankPlayersJunction>()
            .Resolve();
}

Hand the input to the train bus and await the output. The bus finds the train by its input type, runs it in the current request, and records the run.

LeaderboardController.cs
public class LeaderboardController(ITrainBus trains) : ControllerBase
{
    [HttpPost("leaderboard/{region}/recalculate")]
    public Task<RecalculateLeaderboardOutput> Recalculate(string region) =>
        trains.RunAsync<RecalculateLeaderboardOutput>(
            new RecalculateLeaderboardInput { Region = region });
}

Every run leaves a record

API calls, background jobs and cron schedules all go through the same pipeline, and each run gets a row in Postgres: when it started, how it ended, which junction failed and the exception it threw. Turn on parameter saving and the input and output are stored with it.

When a job fails overnight, you open the run and read what happened. The dashboard shows the same rows, and so does a SQL query.

What gets recorded →
trax.metadata, id 88213
Name
Trax.Samples.GameServer.Trains.Leaderboard.RecalculateLeaderboard.IRecalculateLeaderboardTrain
TrainState
Failed
StartTime
2026-09-29 03:15:00.412
EndTime
2026-09-29 03:15:02.087
Input
{ "region": "na" }
FailureJunction
RankPlayersJunction
FailureException
TimeoutException
FailureReason
Score service did not answer within 2s
ManifestId
14 (leaderboard-na)
HostName
worker-2

A train can choose its track at run time

A Switch, Gate or Scale names every track the train can take. At run time a decider answers a typed question about a value in Memory, and the train takes the matching track. Every track is part of the declaration, so the host checks all of them at startup, before the first run.

An answer below its track's confidence bar takes the Otherwise track, and a switch with no Otherwise fails the run instead of guessing. An answer that does not fit the question is never acted on.

protected override Task<Either<Exception, Resolution>> Junctions() =>
    Chain<ParseTicket>()
        .Switch<Ticket, TicketTrack>(tracks => tracks
            .When(TicketTrack.Refund, t => t.Chain<IssueRefund>().Chain<NotifyCustomer>(),
                  requireConfidence: 0.7)
            .When(TicketTrack.Escalate, t => t.Chain<OpenIncident>())
            .When(TicketTrack.SelfServe, t => t.Chain<SendHelpArticle>())
            .RequireConfidence(0.5)
            .Otherwise(t => t.Chain<QueueForHuman>()))
        .Chain<CloseTicket>()
        .Resolve();

Decide asks several questions about one state in a single call, and later steps route on the answers without asking again. Gate routes on a yes or no probability, Switch on a choice and Scale on a level. A track is a chain like any other, so it can ask and route again.

ModeratePostTrain.cs
AddServices(decider)
    .Decide<Post>(q => q.YesNo<ContainsThreat>().Choice<Verdict>().Score<Severity>())
    .Gate<ContainsThreat>(gate => gate
        .Yes(t => t.Chain<TakeDownPost>(), atLeast: 0.7)
        .No(t => t.Switch<Verdict>(verdict => verdict
            .When(Verdict.Allow, a => a.Chain<PublishPost>())
            .When(Verdict.Remove, r => r.Chain<TakeDownPost>())
            .When(Verdict.Review, r => r.Scale<Severity>(scale => scale
                .AtLeast(Severity.Low, s => s.Chain<QueueForModerator>())
                .AtLeast(Severity.High, s => s.Chain<PageTrustAndSafety>())))),
            below: 0.3)
        .Unsure(t => t.Chain<PageTrustAndSafety>()))
    .Resolve();

Every decision is recorded, and a requeue replays it

With AddDecisionRecording, each question a run asks is written to trax.decision before the train acts on it: the question, the answer, the model and decider that gave it, each shadow's answer, every track taken, and why an answer was refused. A run that crashes after deciding still shows what it decided.

Re-queueing a run from the dashboard or with the requeueExecution mutation takes the same tracks as the original, from the recorded answers, instead of asking the model again. An answer is replayed only into a state that hashes the same as the one it was given about; otherwise the decider is asked afresh, and the row says why.

trax.decision, one row per question asked
question_key
TicketTrack
kind
choice
answer
Refund, confidence 0.86
model
bespokelabs/Bespoke-Nimble-9B
decider
SystemOneDecider
shadows
ICandidateDecider: Refund, agreed
routes
Refund
refused
null
replayed
false
state_hash
k1:9c41e07b…

Take the layers you need

Each package adds one thing on top of the one before it. Stop at whatever layer solves your problem, and add the next one when you need it. The trains you already wrote do not change.

Deploy it however you want

Same trains, different topologies. Move from a single process to distributed workers to serverless dispatch by changing a few lines of configuration. The train code stays the same.

01

All-in-one

API, scheduler, and job execution in a single process. The simplest setup, and a good fit for small services, internal tools, or getting started.

Program.cs
builder.Services.AddTrax(trax =>
    trax.AddEffects(effects => effects.UsePostgres(conn))
        .AddMediator(assembly)
        .AddScheduler(scheduler => scheduler
            .Schedule<IProcessOrderTrain>(...)
        )
);

builder.AddTraxDashboard();
builder.Services.AddTraxGraphQL();
02

API + Scheduler

The API handles requests and queues work. A separate scheduler process picks up jobs and runs them locally. Good when you want to isolate heavy processing from your request path.

Api / Program.cs
builder.Services.AddTrax(trax =>
    trax.AddEffects(effects => effects.UsePostgres(conn))
        .AddMediator(assembly)
);
builder.Services.AddTraxGraphQL();
Scheduler / Program.cs
builder.Services.AddTrax(trax =>
    trax.AddEffects(effects => effects.UsePostgres(conn))
        .AddMediator(assembly)
        .AddScheduler(scheduler => scheduler
            .Schedule<IProcessOrderTrain>(...)
        )
);
builder.AddTraxDashboard();
03

Hub + Workers

A hub manages scheduling and the API. Stateless worker nodes poll the database for jobs and execute them independently. Add workers to take more load. Workers coordinate through PostgreSQL row locking, no message broker required.

Hub / Program.cs
builder.Services.AddTrax(trax =>
    trax.AddEffects(effects => effects.UsePostgres(conn))
        .AddMediator(assembly)
        .AddScheduler(scheduler => scheduler
            .Schedule<IProcessOrderTrain>(...)
        )
);
builder.Services.AddTraxGraphQL();
Worker / Program.cs
builder.Services.AddTrax(trax =>
    trax.AddEffects(effects => effects.UsePostgres(conn))
        .AddMediator(assembly)
);
builder.Services.AddTraxWorker(opts => {
    opts.WorkerCount = 4;
    opts.PollingInterval = TimeSpan.FromSeconds(1);
});
04

Ephemeral dispatch

The API hands work to an AWS Lambda function through the AWS SDK. There is no public endpoint to secure: IAM decides who may invoke the function. No persistent worker process, no polling. The function boots, runs one train, and exits. Good for bursty workloads where you don't want idle compute.

Api / Program.cs
builder.Services.AddTrax(trax =>
    trax.AddEffects(effects => effects.UsePostgres(conn))
        .AddMediator(assembly)
        .AddScheduler(scheduler => scheduler
            .UseLambdaWorkers(
                lambda => lambda.FunctionName = "trax-runner",
                routing => routing
                    .ForTrain<IProcessOrderTrain>()
            )
            .UseLambdaRun(
                lambda => lambda.FunctionName = "trax-runner"
            )
        )
);
Runner / Function.cs
public class Function : TraxLambdaFunction
{
    protected override void ConfigureServices(
        IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddTrax(trax =>
            trax.AddEffects(effects =>
                    effects.UsePostgres(conn))
                .AddMediator(assembly)
        );
    }
}
05

Just the scheduler

No API at all. A standalone process that runs trains on a schedule: data processing, ETL, report generation, background maintenance. If you don't need an external trigger, you don't need an API.

Program.cs
builder.Services.AddTrax(trax =>
    trax.AddEffects(effects => effects.UsePostgres(conn))
        .AddMediator(assembly)
        .AddScheduler(scheduler => scheduler
            .Schedule<IDataProcessingTrain>(
                "data-processing",
                new DataProcessingInput(),
                Every.Minutes(5)
            )
            .ThenInclude<IReportingTrain>(...)
        )
);
builder.AddTraxDashboard();

Where Trax stops

What it does not do today, so you can decide before you install it.

A crash restarts the train

If a process dies halfway through a run, the run is marked failed and a scheduled train is retried from its first junction. A decider's recorded answers are replayed rather than asked again, unless the state they were about has changed, but every junction runs again, so junctions that call other systems should be safe to repeat.

Postgres in production

The scheduler coordinates workers with Postgres row locks and advisory locks. SQLite works for a single process and in-memory storage works for tests. There is no SQL Server or MySQL provider.

Not a message bus

Trax runs work and records it. It does not carry messages between services through a broker, or coordinate sagas across them. If that is the heart of your system, a service bus is the better fit.

One language, one region

Trains are C#, and one Postgres database coordinates them. Workflows written in several languages, or replicated across regions, are outside what Trax does.

No tracing export yet

Runs are recorded in the database and logged through ILogger. They are not emitted as OpenTelemetry spans.

Import an existing API

OpenAPI or GraphQL. The CLI reads the schema, generates typed trains and junctions, wires up the hub. You fill in the business logic.

From an OpenAPI 3.0 spec:

$ dotnet tool install -g Trax.Cli
$ dotnet new install Trax.Samples.Templates
$ trax generate --schema ./payments-api.json --output ./Payments --name Payments

Or a GraphQL schema:

$ trax generate --schema ./schema.graphql --output ./Payments --name Payments

What comes out:

Payments/
├── Payments.Hub/              API + Scheduler + Dashboard
│   └── Program.cs
└── Payments.Trains/
    ├── Models/
    │   └── Transaction.cs
    └── Trains/
        └── Transactions/
            ├── ListTransactions/
            │   ├── IListTransactionsTrain.cs
            │   ├── ListTransactionsTrain.cs        [TraxQuery]
            │   └── Junctions/
            │       └── ListTransactionsJunction.cs  ← your code here
            └── ChargeCard/
                ├── IChargeCardTrain.cs
                ├── ChargeCardTrain.cs               [TraxMutation]
                └── Junctions/
                    └── ChargeCardJunction.cs        ← your code here

Fill in the junctions, run the hub.

Packages

42 packages in one repository, released together at one version, all on NuGet and all MIT licensed. There is no commercial edition, and there will not be one.

Trax.CoreTrains, junctions and the chain
2 packages›
Trax.EffectRun recording, storage, decisions and state machines
16 packages›
Trax.MediatorThe train bus
2 packages›
Trax.SchedulerSchedules, retries and workers
4 packages›
Trax.ApiGraphQL, authentication and the client
15 packages›
Trax.DashboardThe monitoring UI
1 package›
Trax.CliThe trax command
1 package›
Trax.SamplesTemplates
1 package›