diff --git a/src/Trax.Effect/Models/Metadata/Metadata.cs b/src/Trax.Effect/Models/Metadata/Metadata.cs
index 5a05cd7..d9fa5ac 100644
--- a/src/Trax.Effect/Models/Metadata/Metadata.cs
+++ b/src/Trax.Effect/Models/Metadata/Metadata.cs
@@ -492,6 +492,32 @@ public override string ToString() =>
///
public dynamic? GetOutputObject() => _outputObject;
+ ///
+ /// Gets the deserialized input object cast to the specified type.
+ /// The input is set before the train's junctions execute, so it is available in all
+ /// lifecycle hooks (OnStarted, OnCompleted, OnFailed, OnCancelled).
+ /// Returns default if the input is null or not of type .
+ ///
+ ///
+ /// For per-train lifecycle hooks, prefer the TrainInput property on
+ /// ServiceTrain<TIn, TOut> which provides the same value without
+ /// requiring a type parameter.
+ ///
+ public T? GetInput() => _inputObject is T typed ? typed : default;
+
+ ///
+ /// Gets the deserialized output object cast to the specified type.
+ /// The output is set after a successful run, so it is only meaningful in OnCompleted.
+ /// Returns default in OnStarted, OnFailed, and OnCancelled.
+ /// Returns default if the output is null or not of type .
+ ///
+ ///
+ /// For per-train lifecycle hooks, prefer the TrainOutput property on
+ /// ServiceTrain<TIn, TOut> which provides the same value without
+ /// requiring a type parameter.
+ ///
+ public T? GetOutput() => _outputObject is T typed ? typed : default;
+
#endregion
///
diff --git a/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs b/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs
index 1852884..eaada7b 100644
--- a/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs
+++ b/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs
@@ -75,6 +75,24 @@ public abstract class ServiceTrain : Train, IServiceTrain<
[JsonIgnore]
public string? CanonicalName { get; set; }
+ ///
+ /// Gets the typed input that was passed to this train. Set before
+ /// executes, so it is available in all lifecycle hooks: ,
+ /// , , and .
+ /// Returns default before the train has been run.
+ ///
+ protected TIn TrainInput =>
+ Metadata is not null && Metadata.GetInputObject() is TIn typed ? typed : default!;
+
+ ///
+ /// Gets the typed output produced by this train. Set after a successful run, so it is
+ /// only meaningful in . Returns default in
+ /// , , and
+ /// because the train either hasn't run yet, failed before producing output, or was cancelled.
+ ///
+ protected TOut TrainOutput =>
+ Metadata is not null && Metadata.GetOutputObject() is TOut typed ? typed : default!;
+
///
/// Gets the canonical train name. Prefers the interface name set at registration time
/// via AddScopedTraxRoute, falling back to the concrete type's FullName for
diff --git a/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/TrainLifecycleOverrideTests.cs b/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/TrainLifecycleOverrideTests.cs
index bbef6d3..51593f4 100644
--- a/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/TrainLifecycleOverrideTests.cs
+++ b/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/TrainLifecycleOverrideTests.cs
@@ -23,6 +23,8 @@ public override ServiceProvider ConfigureServices(IServiceCollection services) =
.AddScopedTraxRoute()
.AddScopedTraxRoute()
.AddScopedTraxRoute()
+ .AddScopedTraxRoute()
+ .AddScopedTraxRoute()
.BuildServiceProvider();
#region OnStarted
@@ -288,6 +290,87 @@ public async Task Run_WithoutSaveTrainParameters_GetOutputObjectStillAvailable()
#endregion
+ #region TypedAccess
+
+ [Test]
+ public async Task Run_OnCompleted_TrainInputIsTyped()
+ {
+ var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService();
+
+ await train.Run("hello");
+
+ train.CapturedInput.Should().Be("hello");
+ }
+
+ [Test]
+ public async Task Run_OnCompleted_TrainOutputIsTyped()
+ {
+ var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService();
+
+ await train.Run("hello");
+
+ train.CapturedOutput.Should().NotBeNull();
+ train.CapturedOutput!.Value.Should().Be("processed:hello");
+ train.CapturedOutput!.Count.Should().Be(42);
+ }
+
+ [Test]
+ public async Task Run_OnFailed_TrainInputIsTyped()
+ {
+ var train = (FailingTypedAccessTrain)
+ Scope.ServiceProvider.GetRequiredService();
+
+ var act = async () => await train.Run("hello");
+ await act.Should().ThrowAsync();
+
+ train.CapturedInput.Should().Be("hello");
+ }
+
+ [Test]
+ public async Task Run_OnFailed_TrainOutputIsDefault()
+ {
+ var train = (FailingTypedAccessTrain)
+ Scope.ServiceProvider.GetRequiredService();
+
+ var act = async () => await train.Run("hello");
+ await act.Should().ThrowAsync();
+
+ train.CapturedOutput.Should().BeNull();
+ }
+
+ [Test]
+ public async Task Run_MetadataGetInput_ReturnsTyped()
+ {
+ var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService();
+
+ await train.Run("hello");
+
+ train.CapturedMetadataInput.Should().Be("hello");
+ }
+
+ [Test]
+ public async Task Run_MetadataGetOutput_ReturnsTyped()
+ {
+ var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService();
+
+ await train.Run("hello");
+
+ train.CapturedMetadataOutput.Should().NotBeNull();
+ train.CapturedMetadataOutput!.Value.Should().Be("processed:hello");
+ }
+
+ [Test]
+ public async Task Run_MetadataGetInput_WrongType_ReturnsDefault()
+ {
+ var train = (TypedAccessTrain)Scope.ServiceProvider.GetRequiredService();
+
+ await train.Run("hello");
+
+ train.CapturedWrongTypeInput.Should().Be(0);
+ }
+
+ #endregion
+
#region Test Trains
private interface IRecordingTrain : IServiceTrain { }
@@ -510,5 +593,53 @@ protected override Task OnCompleted(Metadata metadata, CancellationToken ct)
}
}
+ private interface ITypedAccessTrain : IServiceTrain { }
+
+ private class TypedAccessTrain : ServiceTrain, ITypedAccessTrain
+ {
+ public string? CapturedInput { get; private set; }
+ public TestOutputDto? CapturedOutput { get; private set; }
+ public string? CapturedMetadataInput { get; private set; }
+ public TestOutputDto? CapturedMetadataOutput { get; private set; }
+ public int CapturedWrongTypeInput { get; private set; }
+
+ protected override async Task> RunInternal(string input) =>
+ new TestOutputDto($"processed:{input}", 42);
+
+ protected override Task OnCompleted(Metadata metadata, CancellationToken ct)
+ {
+ CapturedInput = TrainInput;
+ CapturedOutput = TrainOutput;
+ CapturedMetadataInput = metadata.GetInput();
+ CapturedMetadataOutput = metadata.GetOutput();
+ CapturedWrongTypeInput = metadata.GetInput();
+ return Task.CompletedTask;
+ }
+ }
+
+ private interface IFailingTypedAccessTrain : IServiceTrain { }
+
+ private class FailingTypedAccessTrain
+ : ServiceTrain,
+ IFailingTypedAccessTrain
+ {
+ public string? CapturedInput { get; private set; }
+ public TestOutputDto? CapturedOutput { get; private set; }
+
+ protected override async Task> RunInternal(string input) =>
+ new TrainException("Intentional failure");
+
+ protected override Task OnFailed(
+ Metadata metadata,
+ Exception exception,
+ CancellationToken ct
+ )
+ {
+ CapturedInput = TrainInput;
+ CapturedOutput = TrainOutput;
+ return Task.CompletedTask;
+ }
+ }
+
#endregion
}