How-to guide

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 hookAn override on one train
Runs forEvery trainThat train only
Registered withAddLifecycleHook<T>()Nothing: override the method
DependenciesThe hook's constructorThe train's constructor
OrderFirstAfter the registered hooks
FitsConcerns every train shares: metrics, audit, alertsOne train's own follow-up

Either way, an exception from the hook is logged and never fails the run.

A hook for every train

  1. 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.

  2. 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();

    AddLifecycleHook returns TraxEffectBuilder, so put it before the data provider when a later call in the chain needs TraxEffectBuilderWithData.

  3. To read the run's input or output, use metadata.GetInput<T>() and, in OnCompleted, metadata.GetOutput<T>(). Each returns default when 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 metadata carries

SDK Reference

AddLifecycleHook | SaveTrainParameters | UsePostgres