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.
protected override Task<Either<Exception, OrderReceipt>> Junctions() =>
Chain<CheckInventoryJunction>()
.Chain<ChargePaymentJunction>()
.Chain<CreateShipmentJunction>()
.Resolve();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
- Runs
- Breaks
- Recovers
18protected override Task<Either<Exception, ResearchReport>> Junctions() =>19 Chain<PlanResearch>()20 .Switch<ResearchBrief, Source>(tracks =>21 tracks22 .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 scale28 .AtLeast(Depth.Skim, t => t.Chain<SkimSources>())29 .AtLeast(Depth.CrossCheck, t => t.Chain<FetchFullTexts>())30 )31 .Chain<Summarize>()32 .Resolve();
Pick a scenario and press Run.
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#
- ResearchTopicTrain.csthe research train: a Switch and a Scale
- ApproveRefundTrain.csthe refund train: a Gate
- DemoDecider.csthe stand-in decision model
- Program.csthe host: decision recording, junction events, the scheduler
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.
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 →- 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.
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.
- 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.
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.
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(conn))
.AddMediator(assembly)
.AddScheduler(scheduler => scheduler
.Schedule<IProcessOrderTrain>(...)
)
);
builder.AddTraxDashboard();
builder.Services.AddTraxGraphQL();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.
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(conn))
.AddMediator(assembly)
);
builder.Services.AddTraxGraphQL();builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(conn))
.AddMediator(assembly)
.AddScheduler(scheduler => scheduler
.Schedule<IProcessOrderTrain>(...)
)
);
builder.AddTraxDashboard();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.
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(conn))
.AddMediator(assembly)
.AddScheduler(scheduler => scheduler
.Schedule<IProcessOrderTrain>(...)
)
);
builder.Services.AddTraxGraphQL();builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(conn))
.AddMediator(assembly)
);
builder.Services.AddTraxWorker(opts => {
opts.WorkerCount = 4;
opts.PollingInterval = TimeSpan.FromSeconds(1);
});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.
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"
)
)
);public class Function : TraxLambdaFunction
{
protected override void ConfigureServices(
IServiceCollection services,
IConfiguration configuration)
{
services.AddTrax(trax =>
trax.AddEffects(effects =>
effects.UsePostgres(conn))
.AddMediator(assembly)
);
}
}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.
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:
Or a GraphQL schema:
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.