diff --git a/docs/guide/durability/marten/sagas.md b/docs/guide/durability/marten/sagas.md
index 7c3917af8..71b0aa9b9 100644
--- a/docs/guide/durability/marten/sagas.md
+++ b/docs/guide/durability/marten/sagas.md
@@ -9,6 +9,93 @@ IoC container.
See the [Saga with Marten sample project](https://github.com/JasperFx/wolverine/tree/main/src/Samples/OrderSagaSample).
+## Strong-Typed Identifiers
+
+Wolverine supports using strong-typed identifiers (e.g., `OrderId`, `InvoiceId`) as the identity of a Marten saga document. This works the same way that strong-typed identifiers are supported for Marten aggregate types. As long as Marten can resolve the identity type, Wolverine will correctly extract the saga identity from your messages.
+
+Here's an example using the [StronglyTypedId](https://github.com/andrewlock/StronglyTypedId) library:
+
+
+
+```cs
+[StronglyTypedId(Template.Guid)]
+public readonly partial struct OrderSagaId;
+
+public class OrderSagaWorkflow : Wolverine.Saga
+{
+ public OrderSagaId Id { get; set; }
+
+ public string CustomerName { get; set; }
+ public bool ItemsPicked { get; set; }
+ public bool PaymentProcessed { get; set; }
+ public bool Shipped { get; set; }
+
+ public static OrderSagaWorkflow Start(StartOrderSaga command)
+ {
+ return new OrderSagaWorkflow
+ {
+ Id = command.OrderId,
+ CustomerName = command.CustomerName
+ };
+ }
+
+ public void Handle(PickOrderItems command)
+ {
+ ItemsPicked = true;
+ checkForCompletion();
+ }
+
+ public void Handle(ProcessOrderPayment command)
+ {
+ PaymentProcessed = true;
+ checkForCompletion();
+ }
+
+ public void Handle(ShipOrder command)
+ {
+ Shipped = true;
+ checkForCompletion();
+ }
+
+ public void Handle(CancelOrderSaga command)
+ {
+ MarkCompleted();
+ }
+
+ private void checkForCompletion()
+ {
+ if (ItemsPicked && PaymentProcessed && Shipped)
+ {
+ MarkCompleted();
+ }
+ }
+}
+
+// Messages using the strong-typed identifier
+public record StartOrderSaga(OrderSagaId OrderId, string CustomerName);
+public record PickOrderItems(OrderSagaId OrderSagaWorkflowId);
+public record ProcessOrderPayment(OrderSagaId OrderSagaWorkflowId);
+public record ShipOrder(OrderSagaId OrderSagaWorkflowId);
+public record CancelOrderSaga(OrderSagaId OrderSagaWorkflowId);
+
+// Message using [SagaIdentity] attribute with strong-typed ID
+public class CompleteOrderStep
+{
+ [SagaIdentity] public OrderSagaId TheOrderId { get; set; }
+}
+```
+snippet source | anchor
+
+
+The standard saga identity resolution conventions still apply:
+
+1. Properties decorated with `[SagaIdentity]`
+2. A property named `{SagaTypeName}Id` (e.g., `OrderSagaWorkflowId`)
+3. A property named `SagaId`
+4. A property named `Id`
+
+Any strong-typed identifier type that Marten can resolve will work, including types generated by StronglyTypedId, Vogen, or hand-crafted value types.
+
## Optimistic Concurrency
Marten will automatically apply numeric revisioning to Wolverine `Saga` storage, and will increment
diff --git a/src/Persistence/MartenTests/Saga/strong_typed_id_saga.cs b/src/Persistence/MartenTests/Saga/strong_typed_id_saga.cs
new file mode 100644
index 000000000..2693b32c6
--- /dev/null
+++ b/src/Persistence/MartenTests/Saga/strong_typed_id_saga.cs
@@ -0,0 +1,189 @@
+using IntegrationTests;
+using JasperFx.Resources;
+using Marten;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using StronglyTypedIds;
+using Wolverine;
+using Wolverine.Marten;
+using Wolverine.Persistence.Sagas;
+using Wolverine.Tracking;
+
+namespace MartenTests.Saga;
+
+#region sample_strong_typed_id_saga
+
+[StronglyTypedId(Template.Guid)]
+public readonly partial struct OrderSagaId;
+
+public class OrderSagaWorkflow : Wolverine.Saga
+{
+ public OrderSagaId Id { get; set; }
+
+ public string CustomerName { get; set; }
+ public bool ItemsPicked { get; set; }
+ public bool PaymentProcessed { get; set; }
+ public bool Shipped { get; set; }
+
+ public static OrderSagaWorkflow Start(StartOrderSaga command)
+ {
+ return new OrderSagaWorkflow
+ {
+ Id = command.OrderId,
+ CustomerName = command.CustomerName
+ };
+ }
+
+ public void Handle(PickOrderItems command)
+ {
+ ItemsPicked = true;
+ checkForCompletion();
+ }
+
+ public void Handle(ProcessOrderPayment command)
+ {
+ PaymentProcessed = true;
+ checkForCompletion();
+ }
+
+ public void Handle(ShipOrder command)
+ {
+ Shipped = true;
+ checkForCompletion();
+ }
+
+ public void Handle(CancelOrderSaga command)
+ {
+ MarkCompleted();
+ }
+
+ private void checkForCompletion()
+ {
+ if (ItemsPicked && PaymentProcessed && Shipped)
+ {
+ MarkCompleted();
+ }
+ }
+}
+
+// Messages using the strong-typed identifier
+public record StartOrderSaga(OrderSagaId OrderId, string CustomerName);
+public record PickOrderItems(OrderSagaId OrderSagaWorkflowId);
+public record ProcessOrderPayment(OrderSagaId OrderSagaWorkflowId);
+public record ShipOrder(OrderSagaId OrderSagaWorkflowId);
+public record CancelOrderSaga(OrderSagaId OrderSagaWorkflowId);
+
+// Message using [SagaIdentity] attribute with strong-typed ID
+public class CompleteOrderStep
+{
+ [SagaIdentity] public OrderSagaId TheOrderId { get; set; }
+}
+
+#endregion
+
+public class strong_typed_id_saga : PostgresqlContext, IAsyncLifetime
+{
+ private IHost _host;
+
+ public async Task InitializeAsync()
+ {
+ _host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Services.AddMarten(m =>
+ {
+ m.DisableNpgsqlLogging = true;
+ m.Connection(Servers.PostgresConnectionString);
+ m.DatabaseSchemaName = "strong_typed_sagas";
+ }).IntegrateWithWolverine();
+
+ opts.Services.AddResourceSetupOnStartup();
+ }).StartAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _host.StopAsync();
+ _host.Dispose();
+ }
+
+ [Fact]
+ public async Task start_saga_with_strong_typed_id()
+ {
+ var orderId = OrderSagaId.New();
+
+ await _host.InvokeMessageAndWaitAsync(new StartOrderSaga(orderId, "Han Solo"));
+
+ using var session = _host.DocumentStore().QuerySession();
+ var saga = await session.LoadAsync(orderId);
+
+ saga.ShouldNotBeNull();
+ saga.Id.ShouldBe(orderId);
+ saga.CustomerName.ShouldBe("Han Solo");
+ }
+
+ [Fact]
+ public async Task handle_message_with_strong_typed_id_on_existing_saga()
+ {
+ var orderId = OrderSagaId.New();
+
+ await _host.InvokeMessageAndWaitAsync(new StartOrderSaga(orderId, "Luke Skywalker"));
+ await _host.InvokeMessageAndWaitAsync(new PickOrderItems(orderId));
+
+ using var session = _host.DocumentStore().QuerySession();
+ var saga = await session.LoadAsync(orderId);
+
+ saga.ShouldNotBeNull();
+ saga.ItemsPicked.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task complete_saga_with_strong_typed_id()
+ {
+ var orderId = OrderSagaId.New();
+
+ await _host.InvokeMessageAndWaitAsync(new StartOrderSaga(orderId, "Leia Organa"));
+ await _host.InvokeMessageAndWaitAsync(new PickOrderItems(orderId));
+ await _host.InvokeMessageAndWaitAsync(new ProcessOrderPayment(orderId));
+ await _host.InvokeMessageAndWaitAsync(new ShipOrder(orderId));
+
+ using var session = _host.DocumentStore().QuerySession();
+ var saga = await session.LoadAsync(orderId);
+
+ // Saga should be deleted when completed
+ saga.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task cancel_saga_with_strong_typed_id()
+ {
+ var orderId = OrderSagaId.New();
+
+ await _host.InvokeMessageAndWaitAsync(new StartOrderSaga(orderId, "Chewbacca"));
+ await _host.InvokeMessageAndWaitAsync(new CancelOrderSaga(orderId));
+
+ using var session = _host.DocumentStore().QuerySession();
+ var saga = await session.LoadAsync(orderId);
+
+ // Saga should be deleted after cancel (MarkCompleted)
+ saga.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task multiple_steps_with_strong_typed_id()
+ {
+ var orderId = OrderSagaId.New();
+
+ await _host.InvokeMessageAndWaitAsync(new StartOrderSaga(orderId, "Yoda"));
+ await _host.InvokeMessageAndWaitAsync(new PickOrderItems(orderId));
+ await _host.InvokeMessageAndWaitAsync(new ProcessOrderPayment(orderId));
+
+ using var session = _host.DocumentStore().QuerySession();
+ var saga = await session.LoadAsync(orderId);
+
+ saga.ShouldNotBeNull();
+ saga.ItemsPicked.ShouldBeTrue();
+ saga.PaymentProcessed.ShouldBeTrue();
+ saga.Shipped.ShouldBeFalse();
+ }
+}
diff --git a/src/Wolverine/Persistence/Sagas/PullSagaIdFromMessageFrame.cs b/src/Wolverine/Persistence/Sagas/PullSagaIdFromMessageFrame.cs
index 6bb91c734..0ed61a3db 100644
--- a/src/Wolverine/Persistence/Sagas/PullSagaIdFromMessageFrame.cs
+++ b/src/Wolverine/Persistence/Sagas/PullSagaIdFromMessageFrame.cs
@@ -1,4 +1,4 @@
-using System.Reflection;
+using System.Reflection;
using JasperFx.CodeGeneration;
using JasperFx.CodeGeneration.Frames;
using JasperFx.CodeGeneration.Model;
@@ -12,6 +12,7 @@ internal class PullSagaIdFromMessageFrame : SyncFrame
private readonly Type _messageType;
private readonly MemberInfo _sagaIdMember;
private readonly Type? _sagaIdType;
+ private readonly bool _isStrongTypedId;
private Variable? _envelope;
private Variable? _message;
@@ -21,12 +22,14 @@ public PullSagaIdFromMessageFrame(Type messageType, MemberInfo sagaIdMember)
_sagaIdMember = sagaIdMember;
_sagaIdType = sagaIdMember.GetMemberType();
- if (!SagaChain.ValidSagaIdTypes.Contains(_sagaIdType))
+ if (!SagaChain.IsValidSagaIdType(_sagaIdType!))
{
throw new ArgumentOutOfRangeException(nameof(messageType),
- $"SagaId must be one of {SagaChain.ValidSagaIdTypes.Select(x => x.NameInCode()).Join(", ")}, but was {_sagaIdType!.NameInCode()}");
+ $"SagaId must be one of {SagaChain.ValidSagaIdTypes.Select(x => x.NameInCode()).Join(", ")} or a strong-typed identifier, but was {_sagaIdType!.NameInCode()}");
}
+ _isStrongTypedId = !SagaChain.ValidSagaIdTypes.Contains(_sagaIdType);
+
SagaId = new Variable(_sagaIdType!, SagaChain.SagaIdVariableName, this);
}
@@ -34,7 +37,11 @@ public PullSagaIdFromMessageFrame(Type messageType, MemberInfo sagaIdMember)
public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
{
- if (_sagaIdType == typeof(string))
+ if (_isStrongTypedId)
+ {
+ generateStrongTypedIdCode(writer);
+ }
+ else if (_sagaIdType == typeof(string))
{
writer.Write(
$"{_sagaIdType.NameInCode()} {SagaChain.SagaIdVariableName} = {_message!.Usage}.{_sagaIdMember.Name} ?? {_envelope!.Usage}.{nameof(Envelope.SagaId)};");
@@ -66,6 +73,19 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
Next?.GenerateCode(method, writer);
}
+ private void generateStrongTypedIdCode(ISourceWriter writer)
+ {
+ var typeNameInCode = _sagaIdType!.FullNameInCode();
+
+ // For strong-typed identifiers, read directly from the message property
+ writer.Write(
+ $"var {SagaChain.SagaIdVariableName} = {_message!.Usage}.{_sagaIdMember.Name};");
+
+ // Check for default value
+ writer.Write(
+ $"if ({SagaChain.SagaIdVariableName}.Equals(default({typeNameInCode}))) throw new {typeof(IndeterminateSagaStateIdException).FullName}({_envelope!.Usage});");
+ }
+
public override IEnumerable FindVariables(IMethodVariables chain)
{
_message = chain.FindVariable(_messageType);
@@ -74,4 +94,4 @@ public override IEnumerable FindVariables(IMethodVariables chain)
_envelope = chain.FindVariable(typeof(Envelope));
yield return _envelope;
}
-}
\ No newline at end of file
+}
diff --git a/src/Wolverine/Persistence/Sagas/SagaChain.cs b/src/Wolverine/Persistence/Sagas/SagaChain.cs
index 0f5f0de2f..8606478c9 100644
--- a/src/Wolverine/Persistence/Sagas/SagaChain.cs
+++ b/src/Wolverine/Persistence/Sagas/SagaChain.cs
@@ -27,6 +27,21 @@ public class SagaChain : HandlerChain
public const string SagaIdVariableName = "sagaId";
public static readonly Type[] ValidSagaIdTypes = [typeof(Guid), typeof(int), typeof(long), typeof(string)];
+ ///
+ /// Determines whether a type is a valid saga identity type. Supports the standard
+ /// primitive types (Guid, int, long, string) as well as strong-typed identifier types
+ /// (e.g., OrderId wrapping a Guid).
+ ///
+ public static bool IsValidSagaIdType(Type type)
+ {
+ if (ValidSagaIdTypes.Contains(type)) return true;
+
+ // Accept strong-typed identifiers: value types (structs) or reference types
+ // that are not one of the known primitives. Marten and other persistence
+ // providers know how to resolve their underlying storage type.
+ return type is { IsPrimitive: false, IsEnum: false };
+ }
+
public SagaChain(WolverineOptions options, IGrouping grouping, HandlerGraph parent) : base(options, grouping, parent)
{
// After base constructor, saga handlers may have been moved to ByEndpoint (Separated mode).