Getting Started
This page builds one application from an empty folder: a train that greets someone, run in-process first, then recorded in Postgres, scheduled, watched from the dashboard, and exposed over GraphQL. Each step lists every file it changes in full, so you can stop after any of them with something that runs.
Every C# block on this page is compiled in CI against the package versions in the project file below.
Requirements
Trax requires net10.0. Every project that references a Trax package must target it; there is no
net8.0 or net9.0 build. Steps 4 to 6 also need Docker, for Postgres.
Trax's packages depend on EF Core, Npgsql and Microsoft.Extensions.*, so NuGet resolves those
for you. You only need to act if your project also pins one of them directly: the pin has to be
at least the version the Trax package was built against, or the restore fails with NU1605 (NU1109
under Central Package Management) naming the package. The floors as of Trax.Effect 1.57 are:
| Package | Minimum |
|---|---|
Microsoft.EntityFrameworkCore (and .Relational, .InMemory, .Sqlite) | 10.0.12 |
Npgsql | 10.0.3 |
Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.3 |
EFCore.NamingConventions | 10.0.1 |
Microsoft.Extensions.* | 10.0.12 |
A release can raise them; the dependency list on the package's nuget.org page is authoritative.
1. Create the project
dotnet new web -n Greeter
cd GreeterReplace Greeter.csproj with this. It holds every package the page uses, with the step that needs
each one:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- The dashboard's Blazor Server script (step 5). -->
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
</PropertyGroup>
<ItemGroup>
<!-- Steps 2 and 3: trains, the effect system, the train bus. -->
<PackageReference Include="Trax.Effect" Version="1.57.4" />
<PackageReference Include="Trax.Effect.Data.InMemory" Version="1.57.4" />
<PackageReference Include="Trax.Mediator" Version="1.23.3" />
<!-- Step 4: Postgres, saved inputs and outputs, a log line per junction. -->
<PackageReference Include="Trax.Effect.Data.Postgres" Version="1.57.4" />
<PackageReference Include="Trax.Effect.Provider.Parameter" Version="1.57.4" />
<PackageReference Include="Trax.Effect.JunctionProvider.Logging" Version="1.57.4" />
<!-- Step 5: the scheduler and the dashboard. -->
<PackageReference Include="Trax.Scheduler" Version="1.34.2" />
<PackageReference Include="Trax.Dashboard" Version="1.16.0" />
<!-- Step 6: GraphQL, and an API key to call it with. -->
<PackageReference Include="Trax.Api.GraphQL" Version="1.44.2" />
<PackageReference Include="Trax.Api.Auth.ApiKey" Version="1.44.2" />
</ItemGroup>
</Project>The versions are the releases this page is compiled against. Pin exact versions as these do: a
floating Version="1.*" restores whatever was published last, and a Trax minor release can change
an API your code calls. Newer releases are listed on each package's nuget.org page.
2. Define a train
A train is a chain of junctions. Each junction takes one input and produces one output, and a junction that throws stops the chain. The train's input and output are plain types.
Greeting.cs:
using Trax.Effect.Models.Manifest;
namespace Greeter;
// IManifestProperties lets the scheduler store this input (step 5).
public record GreetInput : IManifestProperties
{
public string Name { get; init; } = "";
}
public record Greeting(string Message);Junctions.cs:
using LanguageExt;
using Trax.Core.Junction;
namespace Greeter;
public class ValidateNameJunction : Junction<GreetInput, Unit>
{
public override Task<Unit> Run(GreetInput input)
{
if (string.IsNullOrWhiteSpace(input.Name))
throw new ArgumentException("A greeting needs a name.");
return Task.FromResult(Unit.Default);
}
}
public class BuildGreetingJunction(ILogger<BuildGreetingJunction> logger)
: Junction<GreetInput, Greeting>
{
public override Task<Greeting> Run(GreetInput input)
{
logger.LogInformation("Greeting {Name}", input.Name);
return Task.FromResult(new Greeting($"Hello, {input.Name}!"));
}
}GreetTrain.cs:
using LanguageExt;
using Trax.Effect.Services.ServiceTrain;
namespace Greeter;
public interface IGreetTrain : IServiceTrain<GreetInput, Greeting>;
public class GreetTrain : ServiceTrain<GreetInput, Greeting>, IGreetTrain
{
protected override Task<Either<Exception, Greeting>> Junctions() =>
Chain<ValidateNameJunction>().Chain<BuildGreetingJunction>().Resolve();
}Junctions() declares the chain and does nothing else: Trax reads it at startup to check that
every junction's input is available, and refuses to start if one is not. The interface is how the
rest of the application asks for the train; its full name (Greeter.IGreetTrain) is the name Trax
records runs under. Junctions take constructor dependencies from the container, as
BuildGreetingJunction does with its logger.
3. Run it
Program.cs:
using Greeter;
using Trax.Effect.Data.InMemory.Extensions;
using Trax.Effect.Extensions;
using Trax.Mediator.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UseInMemory()).AddMediator(typeof(Program).Assembly)
);
var app = builder.Build();
app.MapPost(
"/greet",
async (GreetInput input, IGreetTrain train) =>
{
try
{
return Results.Ok(await train.Run(input));
}
catch (ArgumentException ex)
{
return Results.BadRequest(ex.Message);
}
}
);
app.Run();AddMediator scans the assembly and registers every train it finds under its interface, so
IGreetTrain can be injected anywhere. UseInMemory() keeps the record of each run in memory,
which is enough to run without a database.
dotnet run
curl -X POST http://localhost:5000/greet -H 'Content-Type: application/json' -d '{"name":"Ada"}'Use the port dotnet run prints; the examples on this page use 5000. The response is {"message":"Hello, Ada!"}; an empty name returns
400 with the junction's message: Run throws the exception that stopped the chain, as it was
thrown. The run is recorded either way. See Run / RunEither.
4. Record runs in Postgres
Start a database:
docker run -d --name greeter-db -p 5432:5432 \
-e POSTGRES_USER=trax -e POSTGRES_PASSWORD=trax123 -e POSTGRES_DB=trax postgres:17Add the connection string to appsettings.json:
{
"ConnectionStrings": {
"TraxDatabase": "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123"
}
}Program.cs:
using Greeter;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.JunctionProvider.Logging.Extensions;
using Trax.Effect.Provider.Parameter.Extensions;
using Trax.Mediator.Extensions;
var builder = WebApplication.CreateBuilder(args);
var connectionString =
builder.Configuration.GetConnectionString("TraxDatabase")
?? throw new InvalidOperationException("Set ConnectionStrings:TraxDatabase.");
builder.Services.AddTrax(trax =>
trax.AddEffects(effects =>
effects.UsePostgres(connectionString).SaveTrainParameters().AddJunctionLogger()
)
.AddMediator(typeof(Program).Assembly)
);
var app = builder.Build();
app.MapPost(
"/greet",
async (GreetInput input, IGreetTrain train) =>
{
try
{
return Results.Ok(await train.Run(input));
}
catch (ArgumentException ex)
{
return Results.BadRequest(ex.Message);
}
}
);
app.Run();UsePostgres creates and migrates the trax schema when the application starts.
SaveTrainParameters() stores each run's input and output as JSON, and AddJunctionLogger() writes a
log entry before and after every junction, at Debug unless you change it with
SetEffectLogLevel. Run the same curl
again, then look at what was recorded:
docker exec greeter-db psql -U trax -d trax -c \
"select name, train_state, input, output from trax.metadata order by id desc limit 5"Each row is one run, named Greeter.IGreetTrain, with its state, input and output. A failed run
also records the junction that failed and its exception. Metadata lists the
columns.
5. Schedule it, and open the dashboard
Program.cs:
using Greeter;
using Trax.Dashboard.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.JunctionProvider.Logging.Extensions;
using Trax.Effect.Provider.Parameter.Extensions;
using Trax.Mediator.Extensions;
using Trax.Scheduler.Extensions;
using Trax.Scheduler.Services.Scheduling;
var builder = WebApplication.CreateBuilder(args);
var connectionString =
builder.Configuration.GetConnectionString("TraxDatabase")
?? throw new InvalidOperationException("Set ConnectionStrings:TraxDatabase.");
builder.Services.AddTrax(trax =>
trax.AddEffects(effects =>
effects.UsePostgres(connectionString).SaveTrainParameters().AddJunctionLogger()
)
.AddMediator(typeof(Program).Assembly)
.AddScheduler(scheduler =>
scheduler.Schedule<IGreetTrain>(
"greet-world",
new GreetInput { Name = "World" },
Every.Minutes(1)
)
)
);
// The dashboard can queue, run and cancel trains, so it refuses to start until you say who may
// use it. Open in Development; outside it, only callers who satisfy the TraxAdmin policy.
builder.AddTraxDashboard(dashboard =>
{
if (builder.Environment.IsDevelopment())
dashboard.AllowAnonymousDashboard();
else
dashboard.RequirePolicy("TraxAdmin");
});
builder.Services.AddAuthorization(options =>
options.AddPolicy("TraxAdmin", policy => policy.RequireRole("Admin"))
);
var app = builder.Build();
app.UseAuthorization();
app.UseTraxDashboard();
app.MapPost(
"/greet",
async (GreetInput input, IGreetTrain train) =>
{
try
{
return Results.Ok(await train.Run(input));
}
catch (ArgumentException ex)
{
return Results.BadRequest(ex.Message);
}
}
);
app.Run();AddScheduler stores the schedule as a manifest named greet-world when the application starts,
and runs the train every minute on worker threads in this process. The manifest lives in Postgres,
so it survives a restart, and changes made to it from the dashboard are kept.
dotnet run starts in Development (from Properties/launchSettings.json), so the dashboard at
http://localhost:5000/trax is open to you. It shows the runs as they happen, the manifest and its
next run time, and lets you run it now or disable it. Outside Development it asks for the
TraxAdmin policy, an authenticated user with the Admin role, and this application cannot sign
anyone in yet, so there the dashboard refuses every request until you add the authentication your
application uses. See Dashboard for the other ways to choose who may use it.
6. Call it over GraphQL
A train opts into the GraphQL schema with an attribute, and has to say who may call it.
GreetTrain.cs:
using LanguageExt;
using Trax.Effect.Attributes;
using Trax.Effect.Services.ServiceTrain;
namespace Greeter;
public interface IGreetTrain : IServiceTrain<GreetInput, Greeting>;
[TraxAuthorize(Roles = "User")]
[TraxQuery(Description = "Greets someone by name")]
public class GreetTrain : ServiceTrain<GreetInput, Greeting>, IGreetTrain
{
protected override Task<Either<Exception, Greeting>> Junctions() =>
Chain<ValidateNameJunction>().Chain<BuildGreetingJunction>().Resolve();
}Program.cs:
using Trax.Api.Auth.ApiKey;
using Trax.Api.GraphQL.Extensions;
using Trax.Dashboard.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.JunctionProvider.Logging.Extensions;
using Trax.Effect.Provider.Parameter.Extensions;
using Trax.Mediator.Extensions;
using Trax.Scheduler.Extensions;
using Trax.Scheduler.Services.Scheduling;
using Greeter;
var builder = WebApplication.CreateBuilder(args);
var connectionString =
builder.Configuration.GetConnectionString("TraxDatabase")
?? throw new InvalidOperationException("Set ConnectionStrings:TraxDatabase.");
builder.Services.AddTrax(trax =>
trax.AddEffects(effects =>
effects.UsePostgres(connectionString).SaveTrainParameters().AddJunctionLogger()
)
.AddMediator(typeof(Program).Assembly)
.AddScheduler(scheduler =>
scheduler.Schedule<IGreetTrain>(
"greet-world",
new GreetInput { Name = "World" },
Every.Minutes(1)
)
)
);
// A demo key, for Development only: a key containing "do-not-use-in-production" stops the
// application from starting in any other environment.
if (builder.Environment.IsDevelopment())
builder.Services.AddTraxApiKeyAuth(keys =>
keys.Add("greeter-key-do-not-use-in-production", id: "demo", "User")
);
builder.Services.AddAuthentication();
builder.Services.AddAuthorization(options =>
options.AddPolicy("TraxAdmin", policy => policy.RequireRole("Admin"))
);
builder.AddTraxDashboard(dashboard =>
{
if (builder.Environment.IsDevelopment())
dashboard.AllowAnonymousDashboard();
else
dashboard.RequirePolicy("TraxAdmin");
});
builder.Services.AddTraxGraphQL();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseTraxDashboard();
app.UseTraxGraphQL();
app.Run();The /greet endpoint is gone: GraphQL replaces it. [TraxQuery] exposes the train as a query that
runs it and returns its output; a train that changes something takes [TraxMutation] instead, which
can also queue it for the scheduler. The schema needs at least one query, so a host that exposes
only mutations refuses to start. Every exposed train has to carry [TraxAuthorize] or
[TraxAllowAnonymous], or the application refuses to start, so an operation is never public by
accident. This one needs the User role, which the demo key holds.
curl -X POST http://localhost:5000/trax/graphql \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: greeter-key-do-not-use-in-production' \
-d '{"query":"{ discover { greet(input: { name: \"Ada\" }) { message } } }"}'In Development, http://localhost:5000/trax/graphql in a browser opens the Nitro GraphQL IDE, with
the schema to explore. Outside Development the IDE, the schema download and introspection are off
unless you turn them on with AllowIntrospection. There is no key there either, so every
operation is refused until you register real credentials: see API Security.
Where to go next
The trax-hub project template scaffolds this same shape (scheduler, dashboard and GraphQL in one
process) with an in-memory provider: dotnet new install Trax.Samples.Templates, then
dotnet new trax-hub -n MyApp. See Project Templates.
- Core: junctions, Memory, and the chain methods
- Effect: metadata, effect providers, and the
ServiceTrainlifecycle - Mediator: running trains by input type with
ITrainBus - Scheduling: cron schedules, retries, dead letters, dependent jobs
- Dashboard: what each page shows, and authorization
- API: queries, queued mutations, subscriptions
- Samples & Deployment: splitting the API, scheduler and workers into separate processes
Without the effect system, Trax.Core alone runs a Train you construct with new: no database,
no container. Core shows that form.
SDK Reference
Junctions | Chain | Run / RunEither | AddTrax / AddEffects | UseInMemory | UsePostgres | SaveTrainParameters | AddJunctionLogger | AddMediator | AddScheduler | Schedule | AddTraxDashboard | UseTraxDashboard | AddTraxGraphQL / UseTraxGraphQL | TraxQuery / TraxMutation | AddTraxApiKeyAuth