Write a lifecycle hook
A lifecycle hook reacts to a run's outcome (post to a chat channel, emit a metric, clean something up) without the train knowing about it. Pick where it lives first:
| A registered hook | An override on one train | |
|---|---|---|
| Runs for | Every train | That train only |
| Registered with | AddLifecycleHook<T>() | Nothing: override the method |
| Dependencies | The hook's constructor | The train's constructor |
| Order | First | After the registered hooks |
| Fits | Concerns every train shares: metrics, audit, alerts | One train's own follow-up |
Either way, an exception from the hook is logged and never fails the run.
A hook for every train
-
Implement
ITrainLifecycleHook. Every method has a default, so override only the events you need:using Trax.Effect.Models.Metadata; using Trax.Effect.Services.TrainLifecycleHook; public interface ISlackClient { Task PostAsync(string text, CancellationToken ct); } public sealed class SlackNotificationHook(ISlackClient slack) : ITrainLifecycleHook { public Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct) => slack.PostAsync($"Train {metadata.Name} failed: {exception.Message}", ct); }The hook is built for each run from the run's scope, so a scoped dependency is that run's.
-
Register it inside
AddEffects:using Trax.Effect.Data.Postgres.Extensions; using Trax.Effect.Extensions; var builder = WebApplication.CreateBuilder(args); builder.Services.AddTrax(trax => trax .AddEffects(effects => effects .AddLifecycleHook<SlackNotificationHook>() .UsePostgres(builder.Configuration.GetConnectionString("Trax")!) ) ); builder.Build().Run();AddLifecycleHookreturnsTraxEffectBuilder, so put it before the data provider when a later call in the chain needsTraxEffectBuilderWithData. -
To read the run's input or output, use
metadata.GetInput<T>()and, inOnCompleted,metadata.GetOutput<T>(). Each returnsdefaultwhen the run is of another train, so a shared hook can check for the type it cares about.
A hook on one train
Override the method on the train. TrainInput and TrainOutput are typed by the train's own generic arguments:
using Trax.Core.Functional;
using Trax.Effect.Models.Metadata;
using Trax.Effect.Services.EffectJunction;
using Trax.Effect.Services.ServiceTrain;
public record BanPlayerInput(string PlayerId, string Reason);
public interface IBanPlayerTrain : IServiceTrain<BanPlayerInput, Unit>;
public class ApplyBanJunction : EffectJunction<BanPlayerInput, Unit>
{
public override Task<Unit> Run(BanPlayerInput input) => Task.FromResult(Unit.Default);
}
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 {PlayerId}: {Reason}", TrainInput.PlayerId, TrainInput.Reason);
return Task.CompletedTask;
}
protected override Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct)
{
logger.LogWarning("Could not ban {PlayerId}: {Message}", TrainInput.PlayerId, exception.Message);
return Task.CompletedTask;
}
}OnStarted, OnCompleted, OnFailed and OnCancelled can be overridden; there is no per-train OnStateChanged.
See also
- Metadata, what a hook's
metadatacarries