AddLifecycleHook
Registers a lifecycle hook that fires on train state transitions. Use lifecycle hooks to trigger side effects when trains start, complete, fail, or are cancelled, without coupling your train logic to the side effect.
Signatures
Register a hook directly (recommended):
public static TraxEffectBuilder AddLifecycleHook<T>(
this TraxEffectBuilder builder,
bool toggleable = true
) where T : class // ITrainLifecycleHook or ITrainLifecycleHookFactoryWhen T implements ITrainLifecycleHook, a factory is created internally and there is no need to write a factory class. When T implements ITrainLifecycleHookFactory, it is registered directly (advanced).
Register with an existing factory instance:
public static TraxEffectBuilder AddLifecycleHook<TFactory>(
this TraxEffectBuilder builder,
TFactory factory,
bool toggleable = true
) where TFactory : class, ITrainLifecycleHookFactory| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
builder | TraxEffectBuilder | Yes | N/A | The effect configuration builder |
factory | TFactory | No | N/A | An existing factory instance (when not using DI to create it) |
toggleable | bool | No | true | Whether the hook can be enabled/disabled at runtime via IEffectRegistry |
ITrainLifecycleHook
public interface ITrainLifecycleHook
{
Task OnStarted(Metadata metadata, CancellationToken ct) => Task.CompletedTask;
Task OnCompleted(Metadata metadata, CancellationToken ct) => Task.CompletedTask;
Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct) => Task.CompletedTask;
Task OnCancelled(Metadata metadata, CancellationToken ct) => Task.CompletedTask;
Task OnStateChanged(Metadata metadata, CancellationToken ct) => Task.CompletedTask;
}All methods have default implementations that return Task.CompletedTask. Override only the events you care about.
| Method | When it fires |
|---|---|
OnStarted | After the train's metadata is persisted and before the train's junctions execute |
OnCompleted | After a successful run, after output is persisted |
OnFailed | After a failed run, after failure is persisted. Includes an OperationCanceledException nothing asked for, such as an HttpClient timeout |
OnCancelled | After a requested cancellation (the run's token was cancelled or its cancel flag was set), after cancellation is persisted |
OnStateChanged | After each of the four above, on every hook, once that event has run on every hook. A hook that implements both OnStateChanged and a specific event sees each transition twice |
Accessing Train Input and Output
Availability by hook
The input is set before junctions execute; the output is set only after a successful run. This determines which hooks can access each value:
| Hook | TrainInput / GetInput<T>() | TrainOutput / GetOutput<T>() |
|---|---|---|
OnStarted | Yes | No (train hasn't run yet) |
OnCompleted | Yes | Yes |
OnFailed | Yes | No (train failed before producing output) |
OnCancelled | Yes | No (train was cancelled) |
OnStateChanged | Yes | Only after a completion |
When unavailable, both return default (null for reference types, zero for value types).
Typed access via Metadata
Metadata provides generic methods to retrieve the input and output as their original types:
T? GetInput<T>()
T? GetOutput<T>()These return default if the object is null or not of the requested type. Useful in global lifecycle hooks (ITrainLifecycleHook) where you know the train's types:
public class SlackNotificationHook(ISlackClient slack) : ITrainLifecycleHook
{
public async Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct)
{
var input = metadata.GetInput<BanPlayerInput>();
await slack.PostAsync($"Ban failed for player {input?.PlayerId}: {exception.Message}", ct);
}
}Typed access via TrainInput / TrainOutput
Per-train lifecycle hooks have an even simpler option. ServiceTrain<TIn, TOut> exposes typed properties that are automatically resolved from the metadata:
protected TIn TrainInput // available in all hooks
protected TOut TrainOutput // available in OnCompleted (default in OnFailed/OnCancelled)No casting, no type parameters. The types come from the train's generic arguments:
public class BanPlayerTrain(ILogger<BanPlayerTrain> logger)
: ServiceTrain<BanPlayerInput, Unit>, IBanPlayerTrain
{
protected override Task OnCompleted(Metadata metadata, CancellationToken ct)
{
logger.LogInformation("Banned player {PlayerId} for: {Reason}",
TrainInput?.PlayerId, TrainInput?.Reason);
return Task.CompletedTask;
}
}Serialized JSON output
metadata.Output is serialized JSON in OnCompleted hooks, whether or not SaveTrainParameters() is configured. When the output was not stored, it is serialized in memory before hooks fire and is not persisted to the database.
The copy follows the stored copy's rules. An output the parameter effect was told to skip (ExcludeOutput, ShouldSaveOutputs, SaveOutputs = false) is not serialized for the hooks, so metadata.Output is null for that train. Otherwise the copy is bounded by MaxParameterBytes (1 MiB unless you change it), or by 1 MiB when it is null or SaveTrainParameters() is not configured, and an output past the ceiling arrives as {"_truncated": true, "_maxBytes": N}. metadata.GetOutput<T>() still returns the object itself. The decision comes from the registered ILifecycleHookOutputPolicy; SaveTrainParameters() registers one that follows its configuration.
ITrainLifecycleHookFactory (Advanced)
public interface ITrainLifecycleHookFactory
{
ITrainLifecycleHook Create();
// Called for each run with the run's scope. Defaults to Create().
ITrainLifecycleHook Create(IServiceProvider serviceProvider) => Create();
}Most users do not need to implement this interface. AddLifecycleHook<THook>() generates a factory automatically. Use a custom factory only if you need non-standard creation logic. The factory is a singleton; it creates a new hook instance per train execution.
Trax calls Create(IServiceProvider) with the service provider of the run's scope. A factory that builds hooks from services it captured itself gets the root container's, where a scoped service such as IDataContext is either refused (when the container validates scopes, as ASP.NET Core does in Development) or one instance shared by every run in the process. Override Create(IServiceProvider) and resolve from the provider it is given. A factory that implements only Create() keeps working, through the default.
Error Handling
Lifecycle hook exceptions are caught and logged, never propagated. A failing hook will never cause a train to fail. This is intentional: side effects like posting to Grafana or sending Slack notifications should not affect train reliability.
Example: Custom Slack Notification Hook
public class SlackNotificationHook(ISlackClient slack) : ITrainLifecycleHook
{
public async Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct) =>
await slack.PostAsync($"Train {metadata.Name} failed: {exception.Message}", ct);
}Registration:
builder.Services.AddTrax(trax => trax
.AddEffects(effects => effects
.UsePostgres(connectionString)
.AddLifecycleHook<SlackNotificationHook>()
)
.AddMediator(ServiceLifetime.Scoped, typeof(Program).Assembly)
);No factory class needed. Trax creates one internally and resolves your hook's constructor dependencies from DI, from the scope the train runs in. A scoped dependency such as IDataContext is therefore the run's own, not one shared across runs.
Built-in Hooks
| Hook | Package | Description |
|---|---|---|
GraphQLSubscriptionHook | Trax.Api.GraphQL | Publishes lifecycle events to GraphQL subscriptions. Automatically registered by AddTraxGraphQL(). |
| Broadcast hook (internal) | Trax.Effect | Publishes lifecycle events to a message bus for cross-process delivery. Automatically registered by UseBroadcaster(). |
SignalRTrainEventDispatcher | Trax.Effect.Broadcaster.SignalR | Pushes lifecycle events to connected SignalR clients (Blazor, JS). Automatically registered by UseSignalRHub() as both a lifecycle hook (local path) and an ITrainEventHandler (remote path). |
Per-Train Lifecycle Hooks
In addition to global hooks registered via AddLifecycleHook<T>(), individual trains can override lifecycle methods directly on ServiceTrain. This is useful when a hook only makes sense for one specific train and you want to use the train's own injected dependencies.
public class BanPlayerTrain(ILogger<BanPlayerTrain> logger)
: ServiceTrain<BanPlayerInput, Unit>, IBanPlayerTrain
{
protected override Task<Either<Exception, Unit>> Junctions() =>
Chain<ApplyBanJunction>().Resolve();
protected override Task OnCompleted(Metadata metadata, CancellationToken ct)
{
logger.LogInformation("Banned player {PlayerId} for: {Reason}",
TrainInput?.PlayerId, TrainInput?.Reason);
return Task.CompletedTask;
}
protected override Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct)
{
logger.LogWarning("Failed to ban player {PlayerId}: {Message}",
TrainInput?.PlayerId, exception.Message);
return Task.CompletedTask;
}
}No registration needed, just override the method. The available methods match the global hook interface:
| Method | Signature |
|---|---|
OnStarted | protected virtual Task OnStarted(Metadata metadata, CancellationToken ct) |
OnCompleted | protected virtual Task OnCompleted(Metadata metadata, CancellationToken ct) |
OnFailed | protected virtual Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct) |
OnCancelled | protected virtual Task OnCancelled(Metadata metadata, CancellationToken ct) |
All default to no-op (Task.CompletedTask). Override only the ones you need.
In addition to the metadata parameter, per-train hooks can use the TrainInput and TrainOutput properties for typed access to the train's input and output without casting. See Accessing Train Input and Output above.
Global vs Per-Train Hooks
Global (AddLifecycleHook<T>) | Per-Train (override) | |
|---|---|---|
| Scope | Fires for every train | Fires only for the overriding train |
| Registration | AddLifecycleHook<T>() in builder | No registration, just override |
| DI access | Constructor injection on the hook class | Constructor injection on the train itself |
| Execution order | Fires first | Fires after global hooks |
| Error handling | Caught and logged | Caught and logged |
| Use case | Cross-cutting concerns (metrics, audit logs) | Train-specific business logic (alerts, cleanup) |
Package
dotnet add package Trax.Effect