From fc30d1f232f8abb9b633b362b408b5eaee5de7c0 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 9 Mar 2026 16:13:34 -0500 Subject: [PATCH] Support strong-typed identifiers as Marten Saga identity types Expand saga identity validation to accept strong-typed ID types (e.g., OrderId, InvoiceId) in addition to primitive types (Guid, int, long, string). Add code generation support for extracting strong-typed IDs from messages in PullSagaIdFromMessageFrame. Includes integration tests and documentation. Co-Authored-By: Claude Opus 4.6 --- docs/guide/durability/marten/sagas.md | 87 ++++++++ .../MartenTests/Saga/strong_typed_id_saga.cs | 189 ++++++++++++++++++ .../Sagas/PullSagaIdFromMessageFrame.cs | 30 ++- src/Wolverine/Persistence/Sagas/SagaChain.cs | 15 ++ 4 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 src/Persistence/MartenTests/Saga/strong_typed_id_saga.cs 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).