diff --git a/Directory.Packages.props b/Directory.Packages.props
index 269957cc3..cce54041e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -21,13 +21,14 @@
-
-
+
+
-
+
+
-
+
@@ -79,13 +80,13 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/build/build.cs b/build/build.cs
index 2488d6750..f2a5bd349 100644
--- a/build/build.cs
+++ b/build/build.cs
@@ -212,7 +212,7 @@ class Build : NukeBuild
.Executes(() =>
{
DotNetTest(c => c
- .SetProjectFile(Solution.Persistence.SqliteTests)
+ .SetProjectFile(Solution.Persistence.Sqlite.SqliteTests)
.SetConfiguration(Configuration)
.EnableNoBuild()
.EnableNoRestore()
@@ -329,13 +329,13 @@ class Build : NukeBuild
Solution.Transports.Pulsar.Wolverine_Pulsar,
Solution.Transports.GCP.Wolverine_Pubsub,
Solution.Persistence.Wolverine_RDBMS,
- Solution.Persistence.Wolverine_Postgresql,
- Solution.Persistence.Wolverine_Marten,
- Solution.Persistence.Wolverine_RavenDb,
- Solution.Persistence.Wolverine_SqlServer,
- Solution.Persistence.Wolverine_MySql,
- Solution.Persistence.Wolverine_Oracle,
- Solution.Persistence.Wolverine_Sqlite,
+ Solution.Persistence.PostgreSQL.Wolverine_Postgresql,
+ Solution.Persistence.Marten.Wolverine_Marten,
+ Solution.Persistence.RavenDb.Wolverine_RavenDb,
+ Solution.Persistence.SqlServer.Wolverine_SqlServer,
+ Solution.Persistence.MySql.Wolverine_MySql,
+ Solution.Persistence.Oracle.Wolverine_Oracle,
+ Solution.Persistence.Sqlite.Wolverine_Sqlite,
Solution.Persistence.CosmosDb.Wolverine_CosmosDb,
Solution.Extensions.Wolverine_FluentValidation,
Solution.Extensions.Wolverine_MemoryPack,
@@ -478,10 +478,10 @@ private IEnumerable nugetReferences()
{
yield return new(Solution.Wolverine, ["JasperFx", "JasperFx.RuntimeCompiler", "JasperFx.Events"]);
- yield return new(Solution.Persistence.Wolverine_Postgresql, ["Weasel.Postgresql"]);
+ yield return new(Solution.Persistence.PostgreSQL.Wolverine_Postgresql, ["Weasel.Postgresql"]);
yield return new(Solution.Persistence.Wolverine_RDBMS, ["Weasel.Core"]);
- yield return new(Solution.Persistence.Wolverine_SqlServer, ["Weasel.SqlServer"]);
- yield return new(Solution.Persistence.Wolverine_Marten, ["Marten"]);
+ yield return new(Solution.Persistence.SqlServer.Wolverine_SqlServer, ["Weasel.SqlServer"]);
+ yield return new(Solution.Persistence.Marten.Wolverine_Marten, ["Marten"]);
}
Target Attach => _ => _.Executes(() =>
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 96c2dad3e..4dc58abb9 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -81,6 +81,7 @@ const config: UserConfig = {
{text: 'Vertical Slice Architecture', link: '/tutorials/vertical-slice-architecture'},
{text: 'Modular Monoliths', link: '/tutorials/modular-monolith'},
{text: 'Event Sourcing and CQRS with Marten', link: '/tutorials/cqrs-with-marten'},
+ {text: 'Event Sourcing and CQRS with Polecat', link: '/tutorials/cqrs-with-polecat'},
{text: 'Railway Programming with Wolverine', link: '/tutorials/railway-programming'},
{text: 'Interoperability with Non-Wolverine Systems', link: '/tutorials/interop'},
{text: 'Leader Election and Agents', link: '/tutorials/leader-election'},
@@ -232,6 +233,7 @@ const config: UserConfig = {
{text: 'Uploading Files', link: '/guide/http/files'},
{text: 'Integration with Sagas', link: '/guide/http/sagas'},
{text: 'Integration with Marten', link: '/guide/http/marten'},
+ {text: 'Integration with Polecat', link: '/guide/http/polecat'},
{text: 'Validation', link: '/guide/http/validation'},
{text: 'Fluent Validation', link: '/guide/http/fluentvalidation'},
{text: 'Problem Details', link: '/guide/http/problemdetails'},
@@ -259,6 +261,18 @@ const config: UserConfig = {
{text: 'Multi-Tenancy and Marten', link: '/guide/durability/marten/multi-tenancy'},
{text: 'Ancillary Marten Stores', link: '/guide/durability/marten/ancillary-stores'},
]},
+ {text: 'Polecat Integration', link: '/guide/durability/polecat/', collapsed: true, items: [
+ {text: 'Transactional Middleware', link: '/guide/durability/polecat/transactional-middleware'},
+ {text: 'Transactional Outbox Support', link: '/guide/durability/polecat/outbox'},
+ {text: 'Transactional Inbox Support', link: '/guide/durability/polecat/inbox'},
+ {text: 'Operation Side Effects', link: '/guide/durability/polecat/operations'},
+ {text: 'Aggregate Handlers and Event Sourcing', link: '/guide/durability/polecat/event-sourcing'},
+ {text: 'Event Forwarding to Wolverine', link: '/guide/durability/polecat/event-forwarding'},
+ {text: 'Event Subscriptions', link: '/guide/durability/polecat/subscriptions'},
+ {text: 'Subscription/Projection Distribution', link: '/guide/durability/polecat/distribution'},
+ {text: 'Sagas', link: '/guide/durability/polecat/sagas'},
+ {text: 'Multi-Tenancy and Polecat', link: '/guide/durability/polecat/multi-tenancy'},
+ ]},
{text: 'Sql Server Integration', link: '/guide/durability/sqlserver'},
{text: 'PostgreSQL Integration', link: '/guide/durability/postgresql'},
{text: 'MySQL Integration', link: '/guide/durability/mysql'},
diff --git a/docs/guide/durability/marten/event-sourcing.md b/docs/guide/durability/marten/event-sourcing.md
index 5cd8607d1..402576906 100644
--- a/docs/guide/durability/marten/event-sourcing.md
+++ b/docs/guide/durability/marten/event-sourcing.md
@@ -905,6 +905,202 @@ public class when_transfering_money
snippet source | anchor
+### Finer-Grained Optimistic Concurrency in Multi-Stream Operations
+
+When a handler uses multiple `[WriteAggregate]` parameters, Wolverine automatically applies version discovery only
+to the **first** aggregate parameter. Secondary aggregate parameters will **not** automatically look for a `Version`
+variable, preventing them from accidentally sharing the same version source.
+
+To opt a secondary stream into optimistic concurrency checking, use the `VersionSource` property to explicitly point
+it at a different member:
+
+```cs
+public record TransferMoney(Guid FromId, Guid ToId, decimal Amount,
+ long FromVersion, long ToVersion);
+
+public static class TransferMoneyHandler
+{
+ public static void Handle(
+ TransferMoney command,
+
+ // First parameter: discovers "Version" automatically, or use
+ // VersionSource for an explicit member name
+ [WriteAggregate(nameof(TransferMoney.FromId),
+ VersionSource = nameof(TransferMoney.FromVersion))]
+ IEventStream fromAccount,
+
+ // Secondary parameter: only gets version checking if VersionSource is set
+ [WriteAggregate(nameof(TransferMoney.ToId),
+ VersionSource = nameof(TransferMoney.ToVersion))]
+ IEventStream toAccount)
+ {
+ if (fromAccount.Aggregate.Amount >= command.Amount)
+ {
+ fromAccount.AppendOne(new Withdrawn(command.Amount));
+ toAccount.AppendOne(new Debited(command.Amount));
+ }
+ }
+}
+```
+
+You can also use `AlwaysEnforceConsistency` on individual streams within a multi-stream operation to ensure a
+concurrency check even when no events are appended to that stream:
+
+```cs
+public static void Handle(
+ TransferMoney command,
+ [WriteAggregate(nameof(TransferMoney.FromId),
+ AlwaysEnforceConsistency = true)]
+ IEventStream fromAccount,
+ [WriteAggregate(nameof(TransferMoney.ToId))]
+ IEventStream toAccount)
+{
+ // Even if insufficient funds cause no events to be appended
+ // to fromAccount, Marten will still verify its version hasn't changed
+ if (fromAccount.Aggregate.Amount >= command.Amount)
+ {
+ fromAccount.AppendOne(new Withdrawn(command.Amount));
+ toAccount.AppendOne(new Debited(command.Amount));
+ }
+}
+```
+
+## Enforcing Consistency Without New Events
+
+In some cases, your command handler may decide not to emit any new events after evaluating the current aggregate state.
+By default, Marten will silently succeed in this case without checking whether the stream has been modified since it was fetched.
+This can be problematic for cross-stream operations where you need to guarantee that all referenced aggregates are still in the
+state you expect at commit time.
+
+The `AlwaysEnforceConsistency` option tells Marten to perform an optimistic concurrency check on the stream even if no events
+are appended. If another session has written to the stream between your `FetchForWriting()` and `SaveChangesAsync()`, Marten
+will throw a `ConcurrencyException`.
+
+### Using the property on `[AggregateHandler]`
+
+You can set `AlwaysEnforceConsistency = true` on the `[AggregateHandler]` attribute:
+
+```cs
+[AggregateHandler(AlwaysEnforceConsistency = true)]
+public static class MyAggregateHandler
+{
+ public static void Handle(DoSomething command, IEventStream stream)
+ {
+ // Even if no events are appended, Marten will verify
+ // the stream version hasn't changed since it was fetched
+ }
+}
+```
+
+### Using `[ConsistentAggregateHandler]`
+
+For convenience, there is a `[ConsistentAggregateHandler]` attribute that automatically sets `AlwaysEnforceConsistency = true`:
+
+```cs
+[ConsistentAggregateHandler]
+public static class MyAggregateHandler
+{
+ public static void Handle(DoSomething command, IEventStream stream)
+ {
+ // AlwaysEnforceConsistency is automatically true
+ }
+}
+```
+
+### Parameter-level usage with `[ConsistentAggregate]`
+
+When using parameter-level attributes (the `[Aggregate]` pattern), you can use `[ConsistentAggregate]` instead:
+
+```cs
+public static class MyHandler
+{
+ public static void Handle(DoSomething command,
+ [ConsistentAggregate] IEventStream stream)
+ {
+ // AlwaysEnforceConsistency is automatically true
+ }
+}
+```
+
+Or set the property directly on `[Aggregate]`:
+
+```cs
+public static class MyHandler
+{
+ public static void Handle(DoSomething command,
+ [Aggregate(AlwaysEnforceConsistency = true)] IEventStream stream)
+ {
+ // Explicitly opt into consistency enforcement
+ }
+}
+```
+
+## Overriding Version Discovery
+
+By default, Wolverine discovers a version member on your command type by looking for a property or field named `Version`
+of type `int` or `long`. In multi-stream operations where each stream needs its own version source, this convention
+breaks down because you can't have multiple properties all named "Version".
+
+The `VersionSource` property lets you explicitly specify which member supplies the expected stream version for
+optimistic concurrency checks.
+
+### On `[AggregateHandler]`
+
+```cs
+public record TransferMoney(Guid FromId, Guid ToId, decimal Amount, long FromVersion);
+
+[AggregateHandler(VersionSource = nameof(TransferMoney.FromVersion))]
+public static class TransferMoneyHandler
+{
+ public static IEnumerable Handle(TransferMoney command, Account account)
+ {
+ // FromVersion will be checked against the "from" account's stream version
+ yield return new Withdrawn(command.Amount);
+ }
+}
+```
+
+### On `[WriteAggregate]` / `[Aggregate]`
+
+This is particularly useful for multi-stream operations where each stream needs independent version tracking:
+
+```cs
+public record TransferMoney(Guid FromId, Guid ToId, decimal Amount,
+ long FromVersion, long ToVersion);
+
+public static class TransferMoneyHandler
+{
+ public static void Handle(
+ TransferMoney command,
+ [WriteAggregate(nameof(TransferMoney.FromId),
+ VersionSource = nameof(TransferMoney.FromVersion))]
+ IEventStream fromAccount,
+ [WriteAggregate(nameof(TransferMoney.ToId),
+ VersionSource = nameof(TransferMoney.ToVersion))]
+ IEventStream toAccount)
+ {
+ if (fromAccount.Aggregate.Amount >= command.Amount)
+ {
+ fromAccount.AppendOne(new Withdrawn(command.Amount));
+ toAccount.AppendOne(new Debited(command.Amount));
+ }
+ }
+}
+```
+
+For HTTP endpoints, `VersionSource` can resolve from route arguments, query string parameters, or request body members:
+
+```cs
+[WolverinePost("/orders/{orderId}/ship/{expectedVersion}")]
+[EmptyResponse]
+public static OrderShipped Ship(
+ ShipOrder command,
+ [Aggregate(VersionSource = "expectedVersion")] Order order)
+{
+ return new OrderShipped();
+}
+```
+
## Strong Typed Identifiers
If you're so inclined, you can use strong typed identifiers from tools like [Vogen](https://github.com/SteveDunn/Vogen) and [StronglyTypedId](https://github.com/andrewlock/StronglyTypedId)
@@ -1024,4 +1220,33 @@ public static StrongLetterAggregate Handle(
tools do this for you, and value types generated by these tools are
-legal route argument variables for Wolverine.HTTP now.
+legal route argument variables for Wolverine.HTTP now.
+
+## Natural Keys
+
+Marten supports [natural keys](/events/natural-keys) on aggregates, allowing you to look up event streams by a domain-meaningful identifier (like an order number) instead of the internal stream id. Wolverine's aggregate handler workflow fully supports natural keys, letting you route commands to the correct aggregate using a business identifier.
+
+### Defining the Aggregate with a Natural Key
+
+First, define your aggregate with a `[NaturalKey]` property and mark the methods that set the key with `[NaturalKeySource]`:
+
+
+
+
+### Using Natural Keys in Command Handlers
+
+When your command carries the natural key value instead of a stream id, Wolverine can resolve it automatically. The command property should match the aggregate's natural key type:
+
+
+
+
+Wolverine uses the natural key type on the command property to call `FetchForWriting()` under the covers, resolving the stream by the natural key in a single database round-trip.
+
+### Handler Examples
+
+Here are the handlers that process those commands, using `[WriteAggregate]` and `IEventStream`:
+
+
+
+
+For more details on how natural keys work at the Marten level, see the [Marten natural keys documentation](https://martendb.io/events/natural-keys).
diff --git a/docs/guide/durability/polecat/distribution.md b/docs/guide/durability/polecat/distribution.md
new file mode 100644
index 000000000..936b53b43
--- /dev/null
+++ b/docs/guide/durability/polecat/distribution.md
@@ -0,0 +1,66 @@
+# Projection/Subscription Distribution
+
+When Wolverine is combined with Polecat and you're using
+asynchronous projections or any event subscriptions with Polecat, you can achieve potentially greater
+scalability for your system by letting Wolverine distribute the load evenly across a running cluster:
+
+```cs
+opts.Services.AddPolecat(m =>
+ {
+ m.Connection(connectionString);
+
+ m.Projections.Add(ProjectionLifecycle.Async);
+ m.Projections.Add(ProjectionLifecycle.Async);
+ m.Projections.Add(ProjectionLifecycle.Async);
+ })
+ .IntegrateWithWolverine(m =>
+ {
+ // This makes Wolverine distribute the registered projections
+ // and event subscriptions evenly across a running application
+ // cluster
+ m.UseWolverineManagedEventSubscriptionDistribution = true;
+ });
+```
+
+::: tip
+This option replaces the Polecat `AddAsyncDaemon(HotCold)` option and should not be used in combination
+with Polecat's own load distribution.
+:::
+
+With this option, Wolverine is going to ensure that every single known asynchronous event projection and every event
+subscription is running on exactly one running node within your application cluster. Moreover, Wolverine will purposely stop and
+restart projections or subscriptions to spread the running load across your entire cluster of running nodes.
+
+If a node is taken offline, Wolverine will detect that the node is no longer accessible and try to start the missing
+projection/subscription agents on another active node.
+
+_If you run your application on only a single server, Wolverine will of course run all projections and subscriptions
+on just that one server._
+
+Some other facts about this integration:
+
+* Wolverine's agent distribution works with per-tenant database multi-tenancy
+* Wolverine does automatic health checking at the running node level
+* Wolverine can detect when new nodes come online and redistribute work
+* Wolverine is able to support blue/green deployment
+* This capability depends on Wolverine's built-in leadership election
+
+## Uri Structure
+
+The `Uri` structure for event subscriptions or projections is:
+
+```
+event-subscriptions://[event store type]/[event store name]/[database server].[database name]/[relative path of the shard]
+```
+
+For example: `event-subscriptions://polecat/main/localhost.mydb/day/all`
+
+## Requirements
+
+This functionality requires Wolverine to both track running nodes and to send messages between running nodes within your
+clustered Wolverine service. Wolverine will utilize a "database control queue" for this internal messaging if you are using the `AddPolecat().IntegrateWithWolverine()` integration.
+
+Other requirements:
+
+* You cannot disable external transports with `StubAllExternalTransports()`
+* `WolverineOptions.Durability.Mode` must be `Balanced`
diff --git a/docs/guide/durability/polecat/event-forwarding.md b/docs/guide/durability/polecat/event-forwarding.md
new file mode 100644
index 000000000..fddbfec37
--- /dev/null
+++ b/docs/guide/durability/polecat/event-forwarding.md
@@ -0,0 +1,57 @@
+# Event Forwarding
+
+::: tip
+As of Wolverine 2.2, you can use `IEvent` as the message type in a handler as part of the event forwarding when you
+need to utilize Polecat metadata
+:::
+
+::: warning
+The Wolverine team recommends against combining this functionality with **also** using events as either a handler response
+or cascaded messages as the behavior can easily become confusing. Instead, prefer using custom types for handler responses or HTTP response bodies
+instead of the raw event types when using the event forwarding.
+:::
+
+The "Event Forwarding" feature immediately pushes any event captured by Polecat through Wolverine's persistent
+outbox where there is a known subscriber (either a local message handler or a known subscriber rule to that event type).
+The "Event Forwarding" publishes the new events as soon as the containing transaction is successfully committed. This is
+different from the [Event Subscriptions](./subscriptions) in that there is no ordering guarantee, and does require you to
+use the Wolverine transactional middleware for Polecat.
+
+::: tip
+The strong recommendation is to use either subscriptions or event forwarding, but not both in the same application.
+:::
+
+To be clear, this will work for:
+
+* Any event type where the Wolverine application has a message handler for either the event type itself, or `IEvent` where `T` is the event type
+* Any event type where there is a known message subscription for that event type or its wrapping `IEvent` to an external transport
+
+Timing wise, the "event forwarding" happens at the time of committing the transaction for the original message that spawned the
+new events, and the resulting event messages go out as cascading messages only after the original transaction succeeds -- just
+like any other outbox usage. **There is no guarantee about ordering in this case.**
+
+To opt into this feature, chain the `AddPolecat().EventForwardingToWolverine()` call as
+shown below:
+
+```cs
+builder.Services.AddPolecat(opts =>
+ {
+ opts.Connection(connectionString);
+ })
+ .IntegrateWithWolverine()
+ .EventForwardingToWolverine();
+```
+
+This does need to be paired with Wolverine configuration to add
+subscriptions to event types like so:
+
+```cs
+builder.Host.UseWolverine(opts =>
+{
+ opts.PublishMessage()
+ .ToLocalQueue("charting")
+ .UseDurableInbox();
+
+ opts.Policies.AutoApplyTransactions();
+});
+```
diff --git a/docs/guide/durability/polecat/event-sourcing.md b/docs/guide/durability/polecat/event-sourcing.md
new file mode 100644
index 000000000..d9b30d332
--- /dev/null
+++ b/docs/guide/durability/polecat/event-sourcing.md
@@ -0,0 +1,537 @@
+# Aggregate Handlers and Event Sourcing
+
+::: tip
+Only use the "aggregate handler workflow" if you are wanting to potentially write new events to an existing event stream. If all you
+need in a message handler or HTTP endpoint is a read-only copy of an event streamed aggregate from Polecat, use the `[ReadAggregate]` attribute
+instead that has a little bit lighter weight runtime within Polecat.
+:::
+
+The Wolverine + Polecat combination is optimized for efficient and productive development using a [CQRS architecture style](https://martinfowler.com/bliki/CQRS.html) with Polecat's event sourcing support.
+Specifically, let's dive into the responsibilities of a typical command handler in a CQRS with event sourcing architecture:
+
+1. Fetch any current state of the system that's necessary to evaluate or validate the incoming event
+2. *Decide* what events should be emitted and captured in response to an incoming event
+3. Manage concurrent access to system state
+4. Safely commit the new events
+5. Selectively publish some of the events based on system needs to other parts of your system or even external systems
+6. Instrument all of the above
+
+And then lastly, you're going to want some resiliency and selective retry capabilities for concurrent access violations or just normal infrastructure hiccups.
+
+Let's jump right into an example order management system. I'm going to model the order workflow with this aggregate model:
+
+```cs
+public class Item
+{
+ public string Name { get; set; }
+ public bool Ready { get; set; }
+}
+
+public class Order
+{
+ public Order(OrderCreated created)
+ {
+ foreach (var item in created.Items) Items[item.Name] = item;
+ }
+
+ // This would be the stream id
+ public Guid Id { get; set; }
+
+ // This is important, by convention this would
+ // be the version
+ public int Version { get; set; }
+
+ public DateTimeOffset? Shipped { get; private set; }
+
+ public Dictionary Items { get; set; } = new();
+
+ // These methods are used by Polecat to update the aggregate
+ // from the raw events
+ public void Apply(IEvent shipped)
+ {
+ Shipped = shipped.Timestamp;
+ }
+
+ public void Apply(ItemReady ready)
+ {
+ Items[ready.Name].Ready = true;
+ }
+
+ public bool IsReadyToShip()
+ {
+ return Shipped == null && Items.Values.All(x => x.Ready);
+ }
+}
+```
+
+At a minimum, we're going to want a command handler for this command message that marks an order item as ready to ship:
+
+```cs
+// OrderId refers to the identity of the Order aggregate
+public record MarkItemReady(Guid OrderId, string ItemName, int Version);
+```
+
+Wolverine supports the [Decider](https://thinkbeforecoding.com/post/2021/12/17/functional-event-sourcing-decider)
+pattern with Polecat using the `[AggregateHandler]` middleware.
+Using that middleware, we get this slim code:
+
+```cs
+[AggregateHandler]
+public static IEnumerable Handle(MarkItemReady command, Order order)
+{
+ if (order.Items.TryGetValue(command.ItemName, out var item))
+ {
+ item.Ready = true;
+
+ // Mark that the this item is ready
+ yield return new ItemReady(command.ItemName);
+ }
+ else
+ {
+ throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order");
+ }
+
+ // If the order is ready to ship, also emit an OrderReady event
+ if (order.IsReadyToShip())
+ {
+ yield return new OrderReady();
+ }
+}
+```
+
+In the case above, Wolverine is wrapping middleware around our basic command handler to:
+
+1. Fetch the appropriate `Order` aggregate matching the command
+2. Append any new events returned from the handle method to the Polecat event stream for this `Order`
+3. Saves any outstanding changes and commits the Polecat unit of work
+
+::: warning
+There are some open imperfections with Wolverine's code generation against the `[WriteAggregate]` and `[ReadAggregate]`
+usage. For best results, only use these attributes on a parameter within the main HTTP endpoint method and not in `Validate/Before/Load` methods.
+:::
+
+::: info
+The `[Aggregate]` and `[WriteAggregate]` attributes _require the requested stream and aggregate to be found by default_, meaning that the handler or HTTP
+endpoint will be stopped if the requested data is not found. You can explicitly mark individual attributes as `Required=false`.
+:::
+
+Alternatively, there is also the newer `[WriteAggregate]` usage:
+
+```cs
+public static IEnumerable Handle(
+ MarkItemReady command,
+ [WriteAggregate] Order order)
+{
+ if (order.Items.TryGetValue(command.ItemName, out var item))
+ {
+ item.Ready = true;
+ yield return new ItemReady(command.ItemName);
+ }
+ else
+ {
+ throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order");
+ }
+
+ if (order.IsReadyToShip())
+ {
+ yield return new OrderReady();
+ }
+}
+```
+
+The `[WriteAggregate]` attribute also opts into the "aggregate handler workflow", but is placed at the parameter level
+instead of the class level. This was added to extend the "aggregate handler workflow" to operations that involve multiple
+event streams in one transaction.
+
+::: tip
+`[WriteAggregate]` works equally on message handlers as it does on HTTP endpoints.
+:::
+
+## Validation on Stream Existence
+
+By default, the "aggregate handler workflow" does no validation on whether or not the identified event stream actually
+exists at runtime. You can protect against missing streams:
+
+```cs
+public static class ValidatedMarkItemReadyHandler
+{
+ public static IEnumerable Handle(
+ MarkItemReady command,
+
+ // In HTTP this will return a 404 status code and stop
+ // In message handlers, this will log and discard the message
+ [WriteAggregate(Required = true)] Order order) => [];
+
+ [WolverineHandler]
+ public static IEnumerable Handle2(
+ MarkItemReady command,
+ [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith400)] Order order) => [];
+
+ [WolverineHandler]
+ public static IEnumerable Handle3(
+ MarkItemReady command,
+ [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith404)] Order order) => [];
+
+ [WolverineHandler]
+ public static IEnumerable Handle4(
+ MarkItemReady command,
+ [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith404, MissingMessage = "Cannot find Order {0}")] Order order) => [];
+}
+```
+
+### Handler Method Signatures
+
+The aggregate workflow command handler method signature needs to follow these rules:
+
+* Either explicitly use the `[AggregateHandler]` attribute on the handler method **or use the `AggregateHandler` suffix** on the message handler type
+* The first argument should be the command type
+* The 2nd argument should be the aggregate -- either the aggregate itself (`Order`) or wrapped in the `IEventStream` type (`IEventStream`):
+
+```cs
+[AggregateHandler]
+public static void Handle(MarkItemReady command, IEventStream stream)
+{
+ var order = stream.Aggregate;
+
+ if (order.Items.TryGetValue(command.ItemName, out var item))
+ {
+ item.Ready = true;
+ stream.AppendOne(new ItemReady(command.ItemName));
+ }
+ else
+ {
+ throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order");
+ }
+
+ if (order.IsReadyToShip())
+ {
+ stream.AppendOne(new OrderReady());
+ }
+}
+```
+
+As for the return values from these handler methods, you can use:
+
+* It's legal to have **no** return values if you are directly using `IEventStream` to append events
+* `IEnumerable` or `object[]` to denote events to append to the current event stream
+* `IAsyncEnumerable` will also be treated as events to append
+* `Events` to denote a list of events
+* `OutgoingMessages` to refer to additional command messages to be published that should *not* be captured as events
+* `ISideEffect` objects
+* Any other type would be considered to be a separate event type
+
+Here's an alternative using `Events`:
+
+```cs
+[AggregateHandler]
+public static async Task<(Events, OutgoingMessages)> HandleAsync(MarkItemReady command, Order order, ISomeService service)
+{
+ var data = await service.FindDataAsync();
+
+ var messages = new OutgoingMessages();
+ var events = new Events();
+
+ if (order.Items.TryGetValue(command.ItemName, out var item))
+ {
+ item.Ready = true;
+ events += new ItemReady(command.ItemName);
+ }
+ else
+ {
+ throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order");
+ }
+
+ if (order.IsReadyToShip())
+ {
+ events += new OrderReady();
+ messages.Add(new ShipOrder(order.Id));
+ }
+
+ return (events, messages);
+}
+```
+
+### Determining the Aggregate Identity
+
+Wolverine is trying to determine a public member on the command type that refers to the identity
+of the aggregate type. You've got two options, either use the implied naming convention
+where the `OrderId` property is assumed to be the identity of the `Order` aggregate:
+
+```cs
+// OrderId refers to the identity of the Order aggregate
+public record MarkItemReady(Guid OrderId, string ItemName, int Version);
+```
+
+Or decorate a public member on the command class with the `[Identity]` attribute:
+
+```cs
+public class MarkItemReady
+{
+ [Identity] public Guid Id { get; init; }
+ public string ItemName { get; init; }
+}
+```
+
+## Forwarding Events
+
+See [Event Forwarding](./event-forwarding) for more information.
+
+## Returning the Updated Aggregate
+
+A common use case has been to respond with the now updated state of the projected
+aggregate that has just been updated by appending new events.
+
+Wolverine.Polecat has a special response type for message handlers or HTTP endpoints we can use as a directive to tell Wolverine
+to respond with the latest state of a projected aggregate as part of the command execution:
+
+```cs
+[AggregateHandler]
+public static (UpdatedAggregate, Events) Handle(MarkItemReady command, Order order)
+{
+ var events = new Events();
+
+ if (order.Items.TryGetValue(command.ItemName, out var item))
+ {
+ item.Ready = true;
+ events.Add(new ItemReady(command.ItemName));
+ }
+ else
+ {
+ throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order");
+ }
+
+ if (order.IsReadyToShip())
+ {
+ events.Add(new OrderReady());
+ }
+
+ return (new UpdatedAggregate(), events);
+}
+```
+
+The `UpdatedAggregate` type is just a directive to Wolverine to generate the necessary code to call `FetchLatest` and respond with that:
+
+```cs
+public static Task update_and_get_latest(IMessageBus bus, MarkItemReady command)
+{
+ return bus.InvokeAsync(command);
+}
+```
+
+You can also use `UpdatedAggregate` as the response body of an HTTP endpoint with Wolverine.HTTP [as shown here](/guide/http/polecat#responding-with-the-updated-aggregate).
+
+### Passing the Aggregate to Before/Validate/Load Methods
+
+The "[compound handler](/guide/handlers/#compound-handlers)" feature is fully supported within the aggregate handler workflow. You can pass the aggregate type as an argument to any `Before` / `LoadAsync` / `Validate` method:
+
+```cs
+public record RaiseIfValidated(Guid LetterAggregateId);
+
+public static class RaiseIfValidatedHandler
+{
+ public static HandlerContinuation Validate(LetterAggregate aggregate) =>
+ aggregate.ACount == 0 ? HandlerContinuation.Continue : HandlerContinuation.Stop;
+
+ [AggregateHandler]
+ public static IEnumerable Handle(RaiseIfValidated command, LetterAggregate aggregate)
+ {
+ yield return new BEvent();
+ }
+}
+```
+
+## Reading the Latest Version of an Aggregate
+
+If you want to inject the current state of an event sourced aggregate as a parameter into
+a message handler method strictly for information and don't need the heavier "aggregate handler workflow," use the `[ReadAggregate]` attribute:
+
+```cs
+public record FindAggregate(Guid Id);
+
+public static class FindLettersHandler
+{
+ public static LetterAggregateEnvelope Handle(FindAggregate command, [ReadAggregate] LetterAggregate aggregate)
+ {
+ return new LetterAggregateEnvelope(aggregate);
+ }
+}
+```
+
+If the aggregate doesn't exist, the HTTP request will stop with a 404 status code.
+The aggregate/stream identity is found with these rules:
+
+1. You can specify a particular request body property name or route argument
+2. Look for a request body property or route argument named "EntityTypeId"
+3. Look for a request body property or route argument named "Id" or "id"
+
+## Targeting Multiple Streams at Once
+
+It's possible to use the "aggregate handler workflow" while needing to append events to more than one event stream at a time.
+
+::: tip
+You can use read only views of event streams through `[ReadAggregate]` at will, and that will use
+Polecat's `FetchLatest()` API underneath. For appending to multiple streams, use `IEventStream` directly.
+:::
+
+```cs
+public record TransferMoney(Guid FromId, Guid ToId, double Amount);
+
+public static class TransferMoneyHandler
+{
+ [WolverinePost("/accounts/transfer")]
+ public static void Handle(
+ TransferMoney command,
+
+ [WriteAggregate(nameof(TransferMoney.FromId))] IEventStream fromAccount,
+
+ [WriteAggregate(nameof(TransferMoney.ToId))] IEventStream toAccount)
+ {
+ if (fromAccount.Aggregate.Amount >= command.Amount)
+ {
+ fromAccount.AppendOne(new Withdrawn(command.Amount));
+ toAccount.AppendOne(new Debited(command.Amount));
+ }
+ }
+}
+```
+
+### Finer-Grained Optimistic Concurrency in Multi-Stream Operations
+
+When a handler uses multiple `[WriteAggregate]` parameters, Wolverine automatically applies version discovery only
+to the **first** aggregate parameter. To opt a secondary stream into optimistic concurrency checking, use `VersionSource`:
+
+```cs
+public record TransferMoney(Guid FromId, Guid ToId, decimal Amount,
+ long FromVersion, long ToVersion);
+
+public static class TransferMoneyHandler
+{
+ public static void Handle(
+ TransferMoney command,
+
+ [WriteAggregate(nameof(TransferMoney.FromId),
+ VersionSource = nameof(TransferMoney.FromVersion))]
+ IEventStream fromAccount,
+
+ [WriteAggregate(nameof(TransferMoney.ToId),
+ VersionSource = nameof(TransferMoney.ToVersion))]
+ IEventStream toAccount)
+ {
+ if (fromAccount.Aggregate.Amount >= command.Amount)
+ {
+ fromAccount.AppendOne(new Withdrawn(command.Amount));
+ toAccount.AppendOne(new Debited(command.Amount));
+ }
+ }
+}
+```
+
+## Enforcing Consistency Without New Events
+
+The `AlwaysEnforceConsistency` option tells Polecat to perform an optimistic concurrency check on the stream even if no events
+are appended:
+
+```cs
+[AggregateHandler(AlwaysEnforceConsistency = true)]
+public static class MyAggregateHandler
+{
+ public static void Handle(DoSomething command, IEventStream stream)
+ {
+ // Even if no events are appended, Polecat will verify
+ // the stream version hasn't changed since it was fetched
+ }
+}
+```
+
+For convenience, there is a `[ConsistentAggregateHandler]` attribute that automatically sets `AlwaysEnforceConsistency = true`.
+
+### Parameter-level usage with `[ConsistentAggregate]`
+
+```cs
+public static class MyHandler
+{
+ public static void Handle(DoSomething command,
+ [ConsistentAggregate] IEventStream stream)
+ {
+ // AlwaysEnforceConsistency is automatically true
+ }
+}
+```
+
+## Overriding Version Discovery
+
+By default, Wolverine discovers a version member on your command type by looking for a property or field named `Version`
+of type `int` or `long`. The `VersionSource` property lets you explicitly specify which member supplies the expected stream version:
+
+```cs
+public record TransferMoney(Guid FromId, Guid ToId, decimal Amount, long FromVersion);
+
+[AggregateHandler(VersionSource = nameof(TransferMoney.FromVersion))]
+public static class TransferMoneyHandler
+{
+ public static IEnumerable Handle(TransferMoney command, Account account)
+ {
+ yield return new Withdrawn(command.Amount);
+ }
+}
+```
+
+For HTTP endpoints, `VersionSource` can resolve from route arguments, query string parameters, or request body members:
+
+```cs
+[WolverinePost("/orders/{orderId}/ship/{expectedVersion}")]
+[EmptyResponse]
+public static OrderShipped Ship(
+ ShipOrder command,
+ [Aggregate(VersionSource = "expectedVersion")] Order order)
+{
+ return new OrderShipped();
+}
+```
+
+## Strong Typed Identifiers
+
+You can use strong typed identifiers from tools like [Vogen](https://github.com/SteveDunn/Vogen) and [StronglyTypedId](https://github.com/andrewlock/StronglyTypedId)
+within the "Aggregate Handler Workflow." You can also use hand rolled value types that wrap either `Guid` or `string`
+as long as they conform to Polecat's rules about value type identifiers.
+
+```cs
+public record IncrementStrongA(LetterId Id);
+
+public static class StrongLetterHandler
+{
+ public static AEvent Handle(IncrementStrongA command, [WriteAggregate] StrongLetterAggregate aggregate)
+ {
+ return new();
+ }
+}
+```
+
+## Natural Keys
+
+Polecat supports [natural keys](/events/natural-keys) on aggregates, allowing you to look up event streams by a domain-meaningful identifier (like an order number) instead of the internal stream id. Wolverine's aggregate handler workflow fully supports natural keys, letting you route commands to the correct aggregate using a business identifier.
+
+### Defining the Aggregate with a Natural Key
+
+First, define your aggregate with a `[NaturalKey]` property and mark the methods that set the key with `[NaturalKeySource]`:
+
+
+
+
+### Using Natural Keys in Command Handlers
+
+When your command carries the natural key value instead of a stream id, Wolverine can resolve it automatically. The command property should match the aggregate's natural key type:
+
+
+
+
+Wolverine uses the natural key type on the command property to call `FetchForWriting()` under the covers, resolving the stream by the natural key in a single database round-trip.
+
+### Handler Examples
+
+Here are the handlers that process those commands, using `[WriteAggregate]` and `IEventStream`:
+
+
+
+
+For more details on how natural keys work at the Polecat level, see the [Polecat natural keys documentation](/events/natural-keys).
diff --git a/docs/guide/durability/polecat/inbox.md b/docs/guide/durability/polecat/inbox.md
new file mode 100644
index 000000000..e7960c130
--- /dev/null
+++ b/docs/guide/durability/polecat/inbox.md
@@ -0,0 +1,39 @@
+# Polecat as Inbox
+
+On the flip side of using Wolverine's "outbox" support for outgoing messages, you can also choose to use the same message persistence for incoming messages such that
+incoming messages are first persisted to the application's underlying SQL Server database before being processed. While
+you *could* use this with external message brokers like Rabbit MQ, it's more likely this will be valuable for Wolverine's [local queues](/guide/messaging/transports/local).
+
+Back to the sample Polecat + Wolverine integration:
+
+```cs
+var builder = WebApplication.CreateBuilder(args);
+builder.Host.ApplyJasperFxExtensions();
+
+builder.Services.AddPolecat(opts =>
+ {
+ opts.Connection(connectionString);
+ })
+ .IntegrateWithWolverine();
+
+builder.Host.UseWolverine(opts =>
+{
+ // I've added persistent inbox
+ // behavior to the "important"
+ // local queue
+ opts.LocalQueue("important")
+ .UseDurableInbox();
+});
+```
+
+By marking this local queue as persistent, any messages sent to this queue
+in memory are first persisted to the underlying SQL Server database, and deleted when the message is successfully processed. This allows Wolverine to grant a stronger
+delivery guarantee to local messages and even allow messages to be processed if the current application node fails before the message is processed.
+
+Or finally, it's less code to opt into Wolverine's outbox by delegating to the [command bus](/guide/in-memory-bus) functionality:
+
+```cs
+// Delegate directly to Wolverine commands
+app.MapPost("/orders/create2", (CreateOrder command, IMessageBus bus)
+ => bus.InvokeAsync(command));
+```
diff --git a/docs/guide/durability/polecat/index.md b/docs/guide/durability/polecat/index.md
new file mode 100644
index 000000000..48044dd35
--- /dev/null
+++ b/docs/guide/durability/polecat/index.md
@@ -0,0 +1,57 @@
+# Polecat Integration
+
+::: info
+There is also some HTTP specific integration for Polecat with Wolverine. See [Integration with Polecat](/guide/http/polecat) for more information.
+:::
+
+[Polecat](https://github.com/JasperFx/polecat) and Wolverine are sibling projects under the [JasperFx organization](https://github.com/JasperFx), and as such, have quite a bit of synergy when used together. Adding the `WolverineFx.Polecat` Nuget dependency to your application adds the capability to combine Polecat and Wolverine to:
+
+* Simplify persistent handler coding with transactional middleware
+* Use Polecat and SQL Server as a persistent inbox or outbox with Wolverine messaging
+* Support persistent sagas within Wolverine applications
+* Effectively use Wolverine and Polecat together for a [Decider](https://thinkbeforecoding.com/post/2021/12/17/functional-event-sourcing-decider) function workflow with event sourcing
+* Selectively publish events captured by Polecat through Wolverine messaging
+* Process events captured by Polecat through Wolverine message handlers through either [subscriptions](./subscriptions) or the older [event forwarding](./event-forwarding).
+
+## Getting Started
+
+To use the Wolverine integration with Polecat, install the `WolverineFx.Polecat` Nuget into your application. Assuming that you've configured Polecat in your application (and Wolverine itself!), you next need to add the Wolverine integration to Polecat as shown in this sample application bootstrapping:
+
+```cs
+var builder = WebApplication.CreateBuilder(args);
+builder.Host.ApplyJasperFxExtensions();
+
+builder.Services.AddPolecat(opts =>
+ {
+ opts.Connection(connectionString);
+ })
+ .IntegrateWithWolverine();
+
+builder.Host.UseWolverine(opts =>
+{
+ opts.Policies.AutoApplyTransactions();
+});
+```
+
+Using the `IntegrateWithWolverine()` extension method behind your call to `AddPolecat()` will:
+
+* Register the necessary [inbox and outbox](/guide/durability/) database tables with Polecat's database schema management
+* Adds Wolverine's "DurabilityAgent" to your .NET application for the inbox and outbox
+* Makes Polecat the active [saga storage](/guide/durability/sagas) for Wolverine
+* Adds transactional middleware using Polecat to your Wolverine application
+
+## Transactional Middleware
+
+See the [Transactional Middleware](./transactional-middleware) page.
+
+## Polecat as Outbox
+
+See the [Polecat as Outbox](./outbox) page.
+
+## Polecat as Inbox
+
+See the [Polecat as Inbox](./inbox) page.
+
+## Saga Storage
+
+See the [Polecat as Saga Storage](./sagas) page.
diff --git a/docs/guide/durability/polecat/multi-tenancy.md b/docs/guide/durability/polecat/multi-tenancy.md
new file mode 100644
index 000000000..6e1b43963
--- /dev/null
+++ b/docs/guide/durability/polecat/multi-tenancy.md
@@ -0,0 +1,99 @@
+# Multi-Tenancy and Polecat
+
+Wolverine.Polecat fully supports Polecat multi-tenancy features, including both conjoined multi-tenanted documents and full blown
+multi-tenancy through separate databases.
+
+Some important facts to know:
+
+* Wolverine.Polecat's transactional middleware is able to respect the [tenant id from Wolverine](/guide/handlers/multi-tenancy) in resolving an `IDocumentSession`
+* If using a database per tenant(s) strategy with Polecat, Wolverine.Polecat is able to create separate message storage tables in each tenant SQL Server database
+* With the strategy above, you'll need a "master" SQL Server database for tenant neutral operations as well
+* The durability agent is able to work against both the master and all of the tenant databases for reliable messaging
+
+## Database per Tenant
+
+To get started using Wolverine with Polecat's database per tenant strategy, configure Polecat multi-tenancy as you normally
+would, but you also need to specify a "master" database connection string for Wolverine:
+
+```cs
+builder.Services.AddPolecat(m =>
+ {
+ m.MultiTenantedDatabases(tenancy =>
+ {
+ tenancy.AddSingleTenantDatabase("Server=localhost;Database=tenant1;...", "tenant1");
+ tenancy.AddSingleTenantDatabase("Server=localhost;Database=tenant2;...", "tenant2");
+ tenancy.AddSingleTenantDatabase("Server=localhost;Database=tenant3;...", "tenant3");
+ });
+ })
+ .IntegrateWithWolverine(x => x.MainDatabaseConnectionString = connectionString);
+```
+
+And you'll probably want this as well to make sure the message storage is in all the databases upfront:
+
+```cs
+builder.Services.AddResourceSetupOnStartup();
+```
+
+Lastly, this is the Wolverine set up:
+
+```cs
+builder.Host.UseWolverine(opts =>
+{
+ opts.Policies.AutoApplyTransactions();
+ opts.Policies.UseDurableLocalQueues();
+});
+```
+
+From there, you should be ready to use Polecat + Wolverine with usages like:
+
+```cs
+[WolverineDelete("/todoitems/{tenant}")]
+public static void Delete(
+ DeleteTodo command, IDocumentSession session)
+{
+ session.Delete(command.Id);
+}
+```
+
+## Conjoined Multi-Tenancy
+
+For "conjoined" multi-tenancy where there's still just one database:
+
+```cs
+public class TenantedDocument
+{
+ public Guid Id { get; init; }
+ public string TenantId { get; set; }
+ public string Location { get; set; }
+}
+
+public record CreateTenantDocument(Guid Id, string Location);
+
+public static class CreateTenantDocumentHandler
+{
+ public static IPolecatOp Handle(CreateTenantDocument command)
+ {
+ return PolecatOps.Insert(new TenantedDocument{Id = command.Id, Location = command.Location});
+ }
+}
+```
+
+Bootstrapping:
+
+```cs
+_host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Services.AddPolecat(connectionString)
+ .IntegrateWithWolverine();
+
+ opts.Policies.AutoApplyTransactions();
+ }).StartAsync();
+```
+
+And then the calls to `InvokeForTenantAsync()` just work:
+
+```cs
+await bus.InvokeForTenantAsync("one", new CreateTenantDocument(id, "Andor"));
+await bus.InvokeForTenantAsync("two", new CreateTenantDocument(id, "Tear"));
+```
diff --git a/docs/guide/durability/polecat/operations.md b/docs/guide/durability/polecat/operations.md
new file mode 100644
index 000000000..3e2d88830
--- /dev/null
+++ b/docs/guide/durability/polecat/operations.md
@@ -0,0 +1,82 @@
+# Polecat Operation Side Effects
+
+::: tip
+You can certainly write your own `IPolecatOp` implementations and use them as return values in your Wolverine
+handlers
+:::
+
+::: info
+This integration also includes full support for the [storage action side effects](/guide/handlers/side-effects.html#storage-side-effects)
+model when using Polecat with Wolverine.
+:::
+
+The `Wolverine.Polecat` library includes some helpers for Wolverine [side effects](/guide/handlers/side-effects) using
+Polecat with the `IPolecatOp` interface:
+
+```cs
+///
+/// Interface for any kind of Polecat related side effect
+///
+public interface IPolecatOp : ISideEffect
+{
+ void Execute(IDocumentSession session);
+}
+```
+
+The built in side effects can all be used from the `PolecatOps` static class like this HTTP endpoint example:
+
+```cs
+[WolverinePost("/invoices/{invoiceId}/pay")]
+public static IPolecatOp Pay([Entity] Invoice invoice)
+{
+ invoice.Paid = true;
+ return PolecatOps.Store(invoice);
+}
+```
+
+There are existing Polecat ops for storing, inserting, updating, and deleting a document. There's also a specific
+helper for starting a new event stream as shown below:
+
+```cs
+public static class TodoListEndpoint
+{
+ [WolverinePost("/api/todo-lists")]
+ public static (TodoCreationResponse, IStartStream) CreateTodoList(
+ CreateTodoListRequest request
+ )
+ {
+ var listId = CombGuidIdGeneration.NewGuid();
+ var result = new TodoListCreated(listId, request.Title);
+ var startStream = PolecatOps.StartStream(listId, result);
+
+ return (new TodoCreationResponse(listId), startStream);
+ }
+}
+```
+
+The major advantage of using a Polecat side effect is to help keep your Wolverine handlers or HTTP endpoints
+be a pure function that can be easily unit tested through measuring the expected return values. Using `IPolecatOp` also
+helps you utilize synchronous methods for your logic, even though at runtime Wolverine itself will be wrapping asynchronous
+code about your simpler, synchronous code.
+
+## Returning Multiple Polecat Side Effects
+
+Wolverine lets you return zero to many `IPolecatOp` operations as side effects
+from a message handler or HTTP endpoint method like so:
+
+```cs
+public static IEnumerable Handle(AppendManyNamedDocuments command)
+{
+ var number = 1;
+ foreach (var name in command.Names)
+ {
+ yield return PolecatOps.Store(new NamedDocument{Id = name, Number = number++});
+ }
+}
+```
+
+Wolverine will pick up on any return type that can be cast to `IEnumerable`, so for example:
+
+* `IEnumerable`
+* `IPolecatOp[]`
+* `List`
diff --git a/docs/guide/durability/polecat/outbox.md b/docs/guide/durability/polecat/outbox.md
new file mode 100644
index 000000000..17c447e21
--- /dev/null
+++ b/docs/guide/durability/polecat/outbox.md
@@ -0,0 +1,107 @@
+# Polecat as Transactional Outbox
+
+::: tip
+Wolverine's outbox will help you order all outgoing messages until after the database transaction succeeds, but only messages being delivered
+to endpoints explicitly configured to be persistent will be stored in the database.
+:::
+
+One of the most important features in all of Wolverine is the [persistent outbox](https://microservices.io/patterns/data/transactional-outbox.html) support and its easy integration into Polecat.
+
+Here's a common problem when using any kind of messaging strategy. Inside the handling for a single web request, you need to make some immediate writes to
+the backing database for the application, then send a corresponding message out through your asynchronous messaging infrastructure. Easy enough, but here's a few ways
+that could go wrong:
+
+* The message is received and processed before the initial database writes are committed
+* The database transaction fails, but the message was still sent out
+* The database transaction succeeds, but the message infrastructure fails
+
+This is where the "outbox" pattern comes into play to guarantee
+that the outgoing message and database transaction both succeed or fail, and that the message is only sent out after the database transaction has succeeded.
+
+Imagine a simple example where a Wolverine handler is receiving a `CreateOrder` command that will create a new Polecat `Order` document and also publish
+an `OrderCreated` event through Wolverine messaging. Using the outbox, that handler **in explicit, long hand form** is this:
+
+```cs
+public static async Task Handle(
+ CreateOrder command,
+ IDocumentSession session,
+ IMessageBus bus,
+ CancellationToken cancellation)
+{
+ var order = new Order
+ {
+ Description = command.Description
+ };
+
+ // Register the new document with Polecat
+ session.Store(order);
+
+ // Hold on though, this message isn't actually sent
+ // until the Polecat session is committed
+ await bus.SendAsync(new OrderCreated(order.Id));
+
+ // This makes the database commits, *then* flushed the
+ // previously registered messages to Wolverine's sending
+ // agents
+ await session.SaveChangesAsync(cancellation);
+}
+```
+
+When `IDocumentSession.SaveChangesAsync()` is called, Polecat is persisting the new `Order` document **and** creating database records for the outgoing `OrderCreated` message
+in the same transaction. After the database transaction succeeds, the pending messages are automatically sent to Wolverine's sending agents.
+
+Now, let's play "what if:"
+
+* What if the messaging broker is down? As long as the messages are persisted, Wolverine will continue trying to send the persisted outgoing messages until the messaging broker is back up.
+* What if the application dies after the database transaction but before the messages are sent? Wolverine will still be able to send these persisted messages from either another running application node or after the application is restarted.
+
+In the section below on transactional middleware we'll see a shorthand way to simplify the code sample above.
+
+## Outbox with ASP.Net Core
+
+The Wolverine outbox is also usable from within ASP.Net Core controller or Minimal API handler code. Within an MVC controller:
+
+```cs
+public class CreateOrderController : ControllerBase
+{
+ [HttpPost("/orders/create2")]
+ public async Task Create(
+ [FromBody] CreateOrder command,
+ [FromServices] IDocumentSession session,
+ [FromServices] IMessageBus bus)
+ {
+ var order = new Order
+ {
+ Description = command.Description
+ };
+
+ // Register the new document with Polecat
+ session.Store(order);
+
+ // Don't worry, this message doesn't go out until
+ // after the Polecat transaction succeeds
+ await bus.PublishAsync(new OrderCreated(order.Id));
+
+ // Commit the Polecat transaction
+ await session.SaveChangesAsync();
+ }
+}
+```
+
+From a Minimal API:
+
+```cs
+app.MapPost("/orders/create3", async (CreateOrder command, IDocumentSession session, IMessageBus bus) =>
+{
+ var order = new Order
+ {
+ Description = command.Description
+ };
+
+ session.Store(order);
+
+ await bus.PublishAsync(new OrderCreated(order.Id));
+
+ await session.SaveChangesAsync();
+});
+```
diff --git a/docs/guide/durability/polecat/sagas.md b/docs/guide/durability/polecat/sagas.md
new file mode 100644
index 000000000..9855ef86a
--- /dev/null
+++ b/docs/guide/durability/polecat/sagas.md
@@ -0,0 +1,13 @@
+# Polecat as Saga Storage
+
+Polecat is an easy option for [persistent sagas](/guide/durability/sagas) with Wolverine. To opt into using Polecat as your saga storage mechanism in Wolverine, you
+just need to add the `IntegrateWithWolverine()` option to your Polecat configuration as shown in the Polecat Integration [Getting Started](/guide/durability/polecat/#getting-started) section.
+
+When using the Wolverine + Polecat integration, your stateful saga classes should be valid Polecat document types that inherit from Wolverine's `Saga` type, which generally means being a public class with a valid
+identity member. Remember that your handler methods in Wolverine can accept "method injected" dependencies from your underlying
+IoC container.
+
+## Optimistic Concurrency
+
+Polecat will automatically apply numeric revisioning to Wolverine `Saga` storage, and will increment
+the `Version` while handling `Saga` commands to use Polecat's native optimistic concurrency protection.
diff --git a/docs/guide/durability/polecat/subscriptions.md b/docs/guide/durability/polecat/subscriptions.md
new file mode 100644
index 000000000..94308e116
--- /dev/null
+++ b/docs/guide/durability/polecat/subscriptions.md
@@ -0,0 +1,178 @@
+# Event Subscriptions
+
+::: tip
+The older [Event Forwarding](./event-forwarding) feature is a subset of subscriptions that relies on the Polecat transactional middleware in message handlers or HTTP endpoints, but happens at the time of event
+capture whereas the event subscriptions are processed in strict order in a background process through Polecat's async daemon
+subsystem **and do not require you to use the Polecat transactional middleware for every operation**. The **strong suggestion from the Wolverine team is to use one or the other approach, but not both in the same system**.
+:::
+
+Wolverine has the ability to extend Polecat's event subscription functionality to carry out message processing by Wolverine on
+the events being captured by Polecat in strict order. This functionality works through Polecat's async daemon.
+
+There are easy recipes for processing events through Wolverine message handlers, and also for just publishing events
+through Wolverine's normal message publishing to be processed locally or by being propagated through asynchronous messaging
+to other systems.
+
+::: info
+Note that Polecat itself will guarantee that each subscription is only running on one active node at a time.
+:::
+
+## Publish Events as Messages
+
+::: tip
+Unless you really want to publish every single event captured by Polecat, set up event type filters to make the subscription
+do less work at runtime.
+:::
+
+The simplest recipe is to just ask Polecat to publish events -- in strict order -- to Wolverine subscribers:
+
+```cs
+using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Services
+ .AddPolecat(o =>
+ {
+ o.Connection(connectionString);
+ })
+ .IntegrateWithWolverine()
+ .AddAsyncDaemon(DaemonMode.HotCold)
+
+ // This would attempt to publish every non-archived event
+ // from Polecat to Wolverine subscribers
+ .PublishEventsToWolverine("Everything")
+
+ // Or with filtering
+ .PublishEventsToWolverine("Orders", relay =>
+ {
+ relay.FilterIncomingEventsOnStreamType(typeof(Order));
+ relay.Options.SubscribeFromPresent();
+ });
+ }).StartAsync();
+```
+
+First off, what's a "subscriber?" *That* would mean any event that Wolverine recognizes as having:
+
+* A local message handler in the application for the specific event type
+* A local message handler in the application for the specific `IEvent` type
+* Any event type where Wolverine can discover subscribers through routing rules
+
+## Process Events as Messages in Strict Order
+
+In some cases you may want the events to be executed by Wolverine message handlers in strict order:
+
+```cs
+using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Services
+ .AddPolecat(o =>
+ {
+ o.Connection(connectionString);
+ o.Projections.Errors.SkipApplyErrors = true;
+ })
+ .IntegrateWithWolverine()
+ .AddAsyncDaemon(DaemonMode.HotCold)
+ .ProcessEventsWithWolverineHandlersInStrictOrder("Orders", o =>
+ {
+ o.IncludeType();
+ o.Options.SubscribeFromTime(new DateTimeOffset(new DateTime(2023, 12, 1)));
+ });
+ }).StartAsync();
+```
+
+In this recipe, Polecat & Wolverine are working together to call `IMessageBus.InvokeAsync()` on each event in order.
+
+In the case of exceptions from processing the event with Wolverine:
+
+1. Any built in "retry" error handling will kick in to retry the event processing inline
+2. If the retries are exhausted, and `SkipApplyErrors = true`, Wolverine will persist the event to its SQL Server backed dead letter queue and proceed to the next event
+3. If the retries are exhausted, and `SkipApplyErrors = false`, Wolverine will direct Polecat to pause the subscription
+
+## Custom Subscriptions
+
+The base type for all Wolverine subscriptions is the `Wolverine.Polecat.Subscriptions.BatchSubscription` class. If you need
+to do something completely custom, or just to take action on a batch of events at one time, subclass that type:
+
+```cs
+public record CompanyActivated(string Name);
+public record CompanyDeactivated;
+public record NewCompany(Guid Id, string Name);
+
+public class CompanyActivations
+{
+ public List Additions { get; set; } = new();
+ public List Removals { get; set; } = new();
+}
+
+public class CompanyTransferSubscription : BatchSubscription
+{
+ public CompanyTransferSubscription() : base("CompanyTransfer")
+ {
+ IncludeType();
+ IncludeType();
+ }
+
+ public override async Task ProcessEventsAsync(EventRange page, ISubscriptionController controller,
+ IDocumentOperations operations,
+ IMessageBus bus, CancellationToken cancellationToken)
+ {
+ var activations = new CompanyActivations();
+ foreach (var e in page.Events)
+ {
+ switch (e)
+ {
+ case IEvent activated:
+ activations.Additions.Add(new NewCompany(activated.StreamId, activated.Data.Name));
+ break;
+ case IEvent deactivated:
+ activations.Removals.Add(deactivated.StreamId);
+ break;
+ }
+ }
+
+ await bus.PublishAsync(activations);
+ }
+}
+```
+
+And the related code to register this subscription:
+
+```cs
+using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.UseRabbitMq();
+
+ opts.PublishMessage()
+ .ToRabbitExchange("activations");
+
+ opts.Services
+ .AddPolecat(o =>
+ {
+ o.Connection(connectionString);
+ })
+ .IntegrateWithWolverine()
+ .AddAsyncDaemon(DaemonMode.HotCold)
+ .SubscribeToEvents(new CompanyTransferSubscription());
+ }).StartAsync();
+```
+
+## Using IoC Services in Subscriptions
+
+To use IoC services in your subscription, use constructor injection and the `SubscribeToEventsWithServices()` API:
+
+```cs
+using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Services
+ .AddPolecat(o =>
+ {
+ o.Connection(connectionString);
+ })
+ .IntegrateWithWolverine()
+ .AddAsyncDaemon(DaemonMode.HotCold)
+ .SubscribeToEventsWithServices(ServiceLifetime.Scoped);
+ }).StartAsync();
+```
diff --git a/docs/guide/durability/polecat/transactional-middleware.md b/docs/guide/durability/polecat/transactional-middleware.md
new file mode 100644
index 000000000..8af813df4
--- /dev/null
+++ b/docs/guide/durability/polecat/transactional-middleware.md
@@ -0,0 +1,151 @@
+# Transactional Middleware
+
+::: warning
+When using the transactional middleware with Polecat, Wolverine is assuming that there will be a single,
+atomic transaction for the entire message handler. Because of the integration with Wolverine's outbox and
+the Polecat `IDocumentSession`, it is **very strongly** recommended that you do not call `IDocumentSession.SaveChangesAsync()`
+yourself as that may result in unexpected behavior in terms of outgoing messages.
+:::
+
+::: tip
+You will need to make the `IServiceCollection.AddPolecat(...).IntegrateWithWolverine()` call to add this middleware to a Wolverine application.
+:::
+
+It is no longer necessary to mark a handler method with `[Transactional]` if you choose to use the `AutoApplyTransactions()` option as shown below:
+
+```cs
+using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Services.AddPolecat("some connection string")
+ .IntegrateWithWolverine();
+
+ // Opt into using "auto" transaction middleware
+ opts.Policies.AutoApplyTransactions();
+ }).StartAsync();
+```
+
+With this enabled, Wolverine will automatically use the Polecat
+transactional middleware for handlers that have a dependency on `IDocumentSession` (meaning the method takes in `IDocumentSession` or has
+some dependency that itself depends on `IDocumentSession`) as long as the `IntegrateWithWolverine()` call was used in application bootstrapping.
+
+### Opting Out with [NonTransactional]
+
+When using `AutoApplyTransactions()`, there may be specific handlers or HTTP endpoints where you want to explicitly opt out of
+transactional middleware even though they use `IDocumentSession`. You can do this with the `[NonTransactional]` attribute:
+
+```cs
+using Wolverine.Attributes;
+
+public static class MySpecialHandler
+{
+ // This handler will NOT have transactional middleware applied
+ // even when AutoApplyTransactions() is enabled
+ [NonTransactional]
+ public static void Handle(MyCommand command, IDocumentSession session)
+ {
+ // You're managing the session yourself here
+ }
+}
+```
+
+The `[NonTransactional]` attribute can be placed on individual handler methods or on the handler class itself to opt out all methods.
+
+In the previous section we saw an example of incorporating Wolverine's outbox with Polecat transactions. Using Wolverine's transactional middleware support for Polecat, the long hand handler can become this equivalent:
+
+```cs
+// Note that we're able to avoid doing any kind of asynchronous
+// code in this handler
+[Transactional]
+public static OrderCreated Handle(CreateOrder command, IDocumentSession session)
+{
+ var order = new Order
+ {
+ Description = command.Description
+ };
+
+ // Register the new document with Polecat
+ session.Store(order);
+
+ // Utilizing Wolverine's "cascading messages" functionality
+ // to have this message sent through Wolverine
+ return new OrderCreated(order.Id);
+}
+```
+
+Or if you need to take more control over how the outgoing `OrderCreated` message is sent, you can use this slightly different alternative:
+
+```cs
+[Transactional]
+public static ValueTask Handle(
+ CreateOrder command,
+ IDocumentSession session,
+ IMessageBus bus)
+{
+ var order = new Order
+ {
+ Description = command.Description
+ };
+
+ // Register the new document with Polecat
+ session.Store(order);
+
+ // Utilizing Wolverine's "cascading messages" functionality
+ return bus.SendAsync(
+ new OrderCreated(order.Id),
+ new DeliveryOptions { DeliverWithin = 5.Minutes() });
+}
+```
+
+In both cases Wolverine's transactional middleware for Polecat is taking care of registering the Polecat session with Wolverine's outbox before you call into the message handler, and
+also calling Polecat's `IDocumentSession.SaveChangesAsync()` afterward.
+
+::: tip
+This [Transactional] attribute can appear on either the handler class that will apply to all the actions on that class, or on a specific action method.
+:::
+
+If so desired, you *can* also use a policy to apply the Polecat transaction semantics with a policy:
+
+```cs
+public class CommandsAreTransactional : IHandlerPolicy
+{
+ public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceContainer container)
+ {
+ chains
+ .Where(chain => chain.MessageType.Name.EndsWith("Command"))
+ .Each(chain => chain.Middleware.Add(new CreateDocumentSessionFrame(chain)));
+ }
+}
+```
+
+Then add the policy to your application like this:
+
+```cs
+using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ // And actually use the policy
+ opts.Policies.Add();
+ }).StartAsync();
+```
+
+## Using IDocumentOperations
+
+When using the transactional middleware with Polecat, it's best to **not** directly call `IDocumentSession.SaveChangesAsync()`
+yourself because that negates the transactional middleware's ability to mark the transaction boundary and can cause
+unexpected problems with the outbox. As a way of preventing this problem, you can choose to directly use
+Polecat's `IDocumentOperations` as an argument to your handler or endpoint methods, which is effectively `IDocumentSession` minus
+the ability to commit the ongoing unit of work with a `SaveChangesAsync` API.
+
+```cs
+public class CreateDocCommand2Handler
+{
+ [Transactional]
+ public void Handle(
+ CreateDocCommand2 message,
+ IDocumentOperations operations)
+ {
+ operations.Store(new FakeDoc { Id = message.Id });
+ }
+}
+```
diff --git a/docs/guide/http/marten.md b/docs/guide/http/marten.md
index 7d5f59444..3e434cd55 100644
--- a/docs/guide/http/marten.md
+++ b/docs/guide/http/marten.md
@@ -386,6 +386,39 @@ Wolverine can't (yet) handle a signature with multiple event streams of the same
`UpdatedAggregate`.
:::
+## Overriding Version Discovery
+
+By default, Wolverine looks for a variable named `version` (from route arguments, query string, or request body) for
+optimistic concurrency checks. In multi-stream scenarios, you can use the `VersionSource` property on `[Aggregate]` or
+`[WriteAggregate]` to specify a different source:
+
+```cs
+// Version from route argument
+[WolverinePost("/orders/{orderId}/ship/{expectedVersion}")]
+[EmptyResponse]
+public static OrderShipped Ship(
+ ShipOrder command,
+ [Aggregate(VersionSource = "expectedVersion")] Order order)
+{
+ return new OrderShipped();
+}
+
+// Version from request body member
+public record ShipOrderWithVersion(Guid OrderId, long ExpectedVersion);
+
+[WolverinePost("/orders/ship-versioned")]
+[EmptyResponse]
+public static OrderShipped Ship(
+ ShipOrderWithVersion command,
+ [Aggregate(VersionSource = nameof(ShipOrderWithVersion.ExpectedVersion))] Order order)
+{
+ return new OrderShipped();
+}
+```
+
+See [Overriding Version Discovery](/guide/durability/marten/event-sourcing.html#overriding-version-discovery) in the
+aggregate handler workflow documentation for more details and multi-stream examples.
+
## Reading the Latest Version of an Aggregate
::: info
diff --git a/docs/guide/http/polecat.md b/docs/guide/http/polecat.md
new file mode 100644
index 000000000..e17ae09b8
--- /dev/null
+++ b/docs/guide/http/polecat.md
@@ -0,0 +1,231 @@
+# Integration with Polecat
+
+The `Wolverine.Http.Polecat` library adds the ability to more deeply integrate Polecat
+into Wolverine.HTTP by utilizing information from route arguments.
+
+To install that library, use:
+
+```bash
+dotnet add package WolverineFx.Http.Polecat
+```
+
+## Passing Polecat Documents to Endpoint Parameters
+
+::: tip
+The `[Entity]` attribute is supported by both message handlers and HTTP endpoints for loading documents by identity.
+:::
+
+Consider this very common use case: you have an HTTP endpoint that needs to work on a Polecat document that will
+be loaded using the value of one of the route arguments as that document's identity. In a long hand way, that could
+look like this:
+
+```cs
+[WolverineGet("/invoices/longhand/{id}")]
+[ProducesResponseType(404)]
+[ProducesResponseType(200, Type = typeof(Invoice))]
+public static async Task GetInvoice(
+ Guid id,
+ IQuerySession session,
+ CancellationToken cancellationToken)
+{
+ var invoice = await session.LoadAsync(id, cancellationToken);
+ if (invoice == null) return Results.NotFound();
+
+ return Results.Ok(invoice);
+}
+```
+
+Using the `[Entity]` attribute, this becomes much simpler:
+
+```cs
+[WolverineGet("/invoices/{id}")]
+public static Invoice Get([Entity] Invoice invoice)
+{
+ return invoice;
+}
+```
+
+Notice that the `[Entity]` attribute was able to use the "id" route parameter. By default, Wolverine is looking first
+for a route variable named "invoiceId" (the document type name + "Id"), then falling back to looking for "id". You can
+explicitly override the matching of route argument like so:
+
+```cs
+[WolverinePost("/invoices/{number}/approve")]
+public static IPolecatOp Approve([Entity("number")] Invoice invoice)
+{
+ invoice.Approved = true;
+ return PolecatOps.Store(invoice);
+}
+```
+
+In the code above, if the `Invoice` document does not exist, the route will stop and return a status code 404 for Not Found.
+
+If you want your handler executed even if the document does not exist, set `Required` to `false`.
+
+## Polecat Aggregate Workflow
+
+The HTTP endpoints can play inside the full Wolverine + Polecat combination with Wolverine's [specific
+support for Event Sourcing and CQRS](/guide/durability/polecat/event-sourcing).
+
+### Using Route Arguments
+
+::: tip
+The `[Aggregate]` attribute was originally meant for the "aggregate handler workflow" where Wolverine is interacting with
+Polecat with the assumption that it will be appending events to streams and getting you ready for versioning assertions.
+
+If all you need is a read only copy of aggregate data, the `[ReadAggregate]` is a lighter weight option.
+
+Also, the `[WriteAggregate]` attribute has the exact same behavior as the older `[Aggregate]`, but is available in both
+message handlers and HTTP endpoints.
+:::
+
+To opt into the Wolverine + Polecat "aggregate workflow" using data from route arguments for the aggregate id,
+use the `[Aggregate]` attribute on endpoint method parameters:
+
+```cs
+[WolverinePost("/orders/{orderId}/ship2"), EmptyResponse]
+public static OrderShipped Ship(ShipOrder2 command, [Aggregate] Order order)
+{
+ if (order.HasShipped)
+ throw new InvalidOperationException("This has already shipped!");
+
+ return new OrderShipped();
+}
+```
+
+Using this version, you no longer have to supply a command in the request body:
+
+```cs
+[WolverinePost("/orders/{orderId}/ship3"), EmptyResponse]
+public static OrderShipped Ship3([Aggregate] Order order)
+{
+ return new OrderShipped();
+}
+```
+
+A couple notes:
+
+* The return value handling for events follows the same rules as shown in the event sourcing section
+* The endpoints will return a 404 response code if the aggregate does not exist
+* The aggregate id can be set explicitly like `[Aggregate("number")]`
+* This usage will automatically apply the transactional middleware
+
+### Using Request Body
+
+::: tip
+This usage only requires Wolverine.Polecat and does not require the Wolverine.Http.Polecat library
+:::
+
+For context, let's say we have these events and aggregate to model an `Order` workflow:
+
+```cs
+public record MarkItemReady(Guid OrderId, string ItemName, int Version);
+public record OrderShipped;
+public record OrderCreated(Item[] Items);
+public record OrderReady;
+public record ItemReady(string Name);
+
+public class Order
+{
+ public Guid Id { get; set; }
+ public int Version { get; set; }
+ public Dictionary Items { get; set; } = new();
+ public bool HasShipped { get; set; }
+
+ public void Apply(ItemReady ready)
+ {
+ Items[ready.Name].Ready = true;
+ }
+
+ public bool IsReadyToShip()
+ {
+ return Items.Values.All(x => x.Ready);
+ }
+}
+```
+
+To append a single event to an event stream from an HTTP endpoint:
+
+```cs
+[AggregateHandler]
+[WolverinePost("/orders/ship"), EmptyResponse]
+public static OrderShipped Ship(ShipOrder command, Order order)
+{
+ return new OrderShipped();
+}
+```
+
+Or potentially append multiple events using the `Events` type:
+
+```cs
+[AggregateHandler]
+[WolverinePost("/orders/itemready")]
+public static (OrderStatus, Events) Post(MarkItemReady command, Order order)
+{
+ var events = new Events();
+
+ if (order.Items.TryGetValue(command.ItemName, out var item))
+ {
+ item.Ready = true;
+ events += new ItemReady(command.ItemName);
+ }
+ else
+ {
+ throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order");
+ }
+
+ if (order.IsReadyToShip())
+ {
+ events += new OrderReady();
+ }
+
+ return (new OrderStatus(order.Id, order.IsReadyToShip()), events);
+}
+```
+
+### Responding with the Updated Aggregate
+
+See the documentation from the message handlers on using [UpdatedAggregate](/guide/durability/polecat/event-sourcing#returning-the-updated-aggregate) for more background on this topic.
+
+To return the updated state of a projected aggregate from Polecat as the HTTP response:
+
+```cs
+[AggregateHandler]
+[WolverinePost("/orders/{id}/confirm2")]
+public static (UpdatedAggregate, Events) ConfirmDifferent(ConfirmOrder command, Order order)
+{
+ return (
+ new UpdatedAggregate(),
+ [new OrderConfirmed()]
+ );
+}
+```
+
+## Overriding Version Discovery
+
+By default, Wolverine looks for a variable named `version` for optimistic concurrency checks. Use `VersionSource` to specify a different source:
+
+```cs
+[WolverinePost("/orders/{orderId}/ship/{expectedVersion}")]
+[EmptyResponse]
+public static OrderShipped Ship(
+ ShipOrder command,
+ [Aggregate(VersionSource = "expectedVersion")] Order order)
+{
+ return new OrderShipped();
+}
+```
+
+See [Overriding Version Discovery](/guide/durability/polecat/event-sourcing#overriding-version-discovery) for more details.
+
+## Reading the Latest Version of an Aggregate
+
+If you want to inject the current state of an event sourced aggregate as a parameter into
+an HTTP endpoint method, use the `[ReadAggregate]` attribute:
+
+```cs
+[WolverineGet("/orders/latest/{id}")]
+public static Order GetLatest(Guid id, [ReadAggregate] Order order) => order;
+```
+
+If the aggregate doesn't exist, the HTTP request will stop with a 404 status code.
diff --git a/docs/tutorials/cqrs-with-polecat.md b/docs/tutorials/cqrs-with-polecat.md
new file mode 100644
index 000000000..4b047d43b
--- /dev/null
+++ b/docs/tutorials/cqrs-with-polecat.md
@@ -0,0 +1,174 @@
+# Event Sourcing and CQRS with Polecat
+
+::: tip
+This guide assumes some familiarity with Event Sourcing nomenclature.
+:::
+
+Let's get the entire Wolverine + [Polecat](https://github.com/JasperFx/polecat) combination assembled and build a system using CQRS with Event Sourcing!
+
+We're going to start with a simple, headless ASP.Net Core project:
+
+```bash
+dotnet new webapi
+```
+
+Next, add the `WolverineFx.Http.Polecat` Nuget to get Polecat, Wolverine itself, and the full Wolverine + Polecat integration
+including the HTTP integration. Inside the bootstrapping in the `Program` file:
+
+```csharp
+builder.Services.AddPolecat(opts =>
+{
+ var connectionString = builder.Configuration.GetConnectionString("Polecat");
+ opts.Connection(connectionString);
+})
+.IntegrateWithWolverine();
+```
+
+For Wolverine itself:
+
+```csharp
+builder.Host.UseWolverine(opts =>
+{
+ opts.Policies.AutoApplyTransactions();
+});
+
+builder.Services.AddWolverineHttp();
+```
+
+Next, add support for Wolverine.HTTP endpoints:
+
+```csharp
+app.MapWolverineEndpoints();
+```
+
+And lastly, add the extended command line support through Oakton:
+
+```csharp
+return await app.RunOaktonCommands(args);
+```
+
+## Event Types and a Projected Aggregate
+
+In Polecat, a "Projection" is the mechanism of taking raw events and "projecting" them
+into some kind of view, which could be a .NET object persisted to the database as JSON.
+
+```cs
+public class Incident
+{
+ public Guid Id { get; set; }
+ public int Version { get; set; }
+ public IncidentStatus Status { get; set; } = IncidentStatus.Pending;
+ public IncidentCategory? Category { get; set; }
+ public bool HasOutstandingResponseToCustomer { get; set; } = false;
+
+ public Incident() { }
+
+ public void Apply(IncidentLogged _) { }
+ public void Apply(AgentRespondedToIncident _) => HasOutstandingResponseToCustomer = false;
+ public void Apply(CustomerRespondedToIncident _) => HasOutstandingResponseToCustomer = true;
+ public void Apply(IncidentResolved _) => Status = IncidentStatus.Resolved;
+ public void Apply(IncidentClosed _) => Status = IncidentStatus.Closed;
+}
+```
+
+And some event types:
+
+```cs
+public record IncidentLogged(Guid CustomerId, Contact Contact, string Description, Guid LoggedBy);
+public record IncidentCategorised(Guid IncidentId, IncidentCategory Category, Guid CategorisedBy);
+public record IncidentClosed(Guid ClosedBy);
+```
+
+## Start a New Stream
+
+Here's the HTTP endpoint that will log a new incident by starting a new event stream:
+
+```cs
+public record LogIncident(Guid CustomerId, Contact Contact, string Description, Guid LoggedBy);
+
+public static class LogIncidentEndpoint
+{
+ [WolverinePost("/api/incidents")]
+ public static (CreationResponse, IStartStream) Post(LogIncident command)
+ {
+ var (customerId, contact, description, loggedBy) = command;
+
+ var logged = new IncidentLogged(customerId, contact, description, loggedBy);
+ var start = PolecatOps.StartStream(logged);
+
+ var response = new CreationResponse("/api/incidents/" + start.StreamId, start.StreamId);
+
+ return (response, start);
+ }
+}
+```
+
+The `IStartStream` interface is a [Polecat specific "side effect"](/guide/durability/polecat/operations) type. `PolecatOps.StartStream()` assigns a new sequential `Guid` value for the new incident.
+
+One of the biggest advantages of Wolverine is that it allows you to use pure functions for many handlers, and the unit test becomes just:
+
+```cs
+[Fact]
+public void unit_test()
+{
+ var contact = new Contact(ContactChannel.Email);
+ var command = new LogIncident(Guid.NewGuid(), contact, "It's broken", Guid.NewGuid());
+
+ var (response, startStream) = LogIncidentEndpoint.Post(command);
+
+ startStream.Events.ShouldBe([
+ new IncidentLogged(command.CustomerId, command.Contact, command.Description, command.LoggedBy)
+ ]);
+}
+```
+
+## Appending Events to an Existing Stream
+
+Let's write an HTTP endpoint to accept a `CategoriseIncident` command using the [aggregate handler workflow](/guide/durability/polecat/event-sourcing):
+
+```cs
+public record CategoriseIncident(IncidentCategory Category, Guid CategorisedBy, int Version);
+
+public static class CategoriseIncidentEndpoint
+{
+ public static ProblemDetails Validate(Incident incident)
+ {
+ return incident.Status == IncidentStatus.Closed
+ ? new ProblemDetails { Detail = "Incident is already closed" }
+ : WolverineContinue.NoProblems;
+ }
+
+ [EmptyResponse]
+ [WolverinePost("/api/incidents/{incidentId:guid}/category")]
+ public static IncidentCategorised Post(
+ CategoriseIncident command,
+ [Aggregate("incidentId")] Incident incident)
+ {
+ return new IncidentCategorised(incident.Id, command.Category, command.CategorisedBy);
+ }
+}
+```
+
+Behind the scenes, Wolverine is using Polecat's `FetchForWriting` API which sets up optimistic concurrency checks.
+
+## Publishing or Handling Events
+
+The Wolverine + Polecat combination comes with two main ways to process events:
+
+[Event Forwarding](/guide/durability/polecat/event-forwarding) is a lightweight way to immediately publish events through Wolverine's messaging infrastructure. Note that event forwarding comes with **no ordering guarantees**.
+
+[Event Subscriptions](/guide/durability/polecat/subscriptions) utilizes a **strictly ordered mechanism** to read in and process event data from the Polecat event store. Wolverine supports three modes:
+
+1. Executing each event with a Wolverine message handler in strict order
+2. Publishing the events as messages through Wolverine in strict order
+3. User defined operations on a batch of events at a time
+
+## Scaling Polecat Projections
+
+Wolverine has the ability to distribute the asynchronous projections and subscriptions to Polecat events evenly across
+an application cluster for better scalability. See [Projection/Subscription Distribution](/guide/durability/polecat/distribution) for more information.
+
+## Observability
+
+Both Polecat and Wolverine have strong support for OpenTelemetry tracing as well as emitting performance
+metrics. See [Wolverine's Otel and Metrics](/guide/logging#open-telemetry) support for more information.
diff --git a/src/Http/Wolverine.Http.Marten/ConsistentAggregateAttribute.cs b/src/Http/Wolverine.Http.Marten/ConsistentAggregateAttribute.cs
new file mode 100644
index 000000000..bc82c3f47
--- /dev/null
+++ b/src/Http/Wolverine.Http.Marten/ConsistentAggregateAttribute.cs
@@ -0,0 +1,20 @@
+using Wolverine.Marten;
+
+namespace Wolverine.Http.Marten;
+
+///
+/// Marks a parameter to an HTTP endpoint as being part of the Marten event sourcing
+/// "aggregate handler" workflow with set to true,
+/// meaning Marten will enforce an optimistic concurrency check on referenced streams even if no events are appended.
+///
+[AttributeUsage(AttributeTargets.Parameter)]
+public class ConsistentAggregateAttribute : Wolverine.Marten.ConsistentAggregateAttribute
+{
+ public ConsistentAggregateAttribute()
+ {
+ }
+
+ public ConsistentAggregateAttribute(string? routeOrParameterName) : base(routeOrParameterName)
+ {
+ }
+}
diff --git a/src/Http/Wolverine.Http.Tests/Marten/using_version_source_override.cs b/src/Http/Wolverine.Http.Tests/Marten/using_version_source_override.cs
new file mode 100644
index 000000000..ba632cd57
--- /dev/null
+++ b/src/Http/Wolverine.Http.Tests/Marten/using_version_source_override.cs
@@ -0,0 +1,84 @@
+using Marten;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using WolverineWebApi.Marten;
+
+namespace Wolverine.Http.Tests.Marten;
+
+public class using_version_source_override(AppFixture fixture) : IntegrationContext(fixture)
+{
+ private async Task CreateOrder()
+ {
+ var result = await Scenario(x =>
+ {
+ x.Post.Json(new StartOrder(["Socks", "Shoes", "Shirt"])).ToUrl("/orders/create");
+ });
+
+ var status = result.ReadAsJson();
+ return status.OrderId;
+ }
+
+ [Fact]
+ public async Task happy_path_with_version_source_from_route_argument()
+ {
+ var orderId = await CreateOrder();
+
+ // version 1 matches the stream after creation
+ await Scenario(x =>
+ {
+ x.Post.Json(new ShipOrderWithExpectedVersion(orderId, 1))
+ .ToUrl($"/orders/{orderId}/ship-with-expected-version/1");
+ x.StatusCodeShouldBe(204);
+ });
+
+ await using var session = Store.LightweightSession();
+ var order = await session.Events.AggregateStreamAsync(orderId);
+ order.ShouldNotBeNull();
+ order.Shipped.HasValue.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task wrong_version_from_route_argument_returns_500()
+ {
+ var orderId = await CreateOrder();
+
+ // version 99 does not match - should fail
+ await Scenario(x =>
+ {
+ x.Post.Json(new ShipOrderWithExpectedVersion(orderId, 99))
+ .ToUrl($"/orders/{orderId}/ship-with-expected-version/99");
+ x.StatusCodeShouldBe(500);
+ });
+ }
+
+ [Fact]
+ public async Task happy_path_with_version_source_from_request_body()
+ {
+ var orderId = await CreateOrder();
+
+ await Scenario(x =>
+ {
+ x.Post.Json(new ShipOrderWithExpectedVersion(orderId, 1))
+ .ToUrl("/orders/ship-with-body-version");
+ x.StatusCodeShouldBe(204);
+ });
+
+ await using var session = Store.LightweightSession();
+ var order = await session.Events.AggregateStreamAsync(orderId);
+ order.ShouldNotBeNull();
+ order.Shipped.HasValue.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task wrong_version_from_request_body_returns_500()
+ {
+ var orderId = await CreateOrder();
+
+ await Scenario(x =>
+ {
+ x.Post.Json(new ShipOrderWithExpectedVersion(orderId, 99))
+ .ToUrl("/orders/ship-with-body-version");
+ x.StatusCodeShouldBe(500);
+ });
+ }
+}
diff --git a/src/Http/WolverineWebApi/Marten/VersionSourceEndpoints.cs b/src/Http/WolverineWebApi/Marten/VersionSourceEndpoints.cs
new file mode 100644
index 000000000..f47e8bca9
--- /dev/null
+++ b/src/Http/WolverineWebApi/Marten/VersionSourceEndpoints.cs
@@ -0,0 +1,31 @@
+using Wolverine.Http;
+using Wolverine.Http.Marten;
+using Wolverine.Marten;
+using WolverineWebApi.Marten;
+
+namespace WolverineWebApi.Marten;
+
+public record ShipOrderWithExpectedVersion(Guid OrderId, long ExpectedVersion);
+
+public static class VersionSourceEndpoints
+{
+ // Route argument version with custom name
+ [WolverinePost("/orders/{orderId}/ship-with-expected-version/{expectedVersion}")]
+ [EmptyResponse]
+ public static OrderShipped ShipWithRouteVersion(
+ ShipOrderWithExpectedVersion command,
+ [Aggregate(VersionSource = "expectedVersion")] Order order)
+ {
+ return new OrderShipped();
+ }
+
+ // Request body member version with custom name
+ [WolverinePost("/orders/ship-with-body-version")]
+ [EmptyResponse]
+ public static OrderShipped ShipWithBodyVersion(
+ ShipOrderWithExpectedVersion command,
+ [Aggregate(VersionSource = nameof(ShipOrderWithExpectedVersion.ExpectedVersion))] Order order)
+ {
+ return new OrderShipped();
+ }
+}
diff --git a/src/Persistence/EfCoreTests/Bug_252_codegen_issue.cs b/src/Persistence/EfCoreTests/Bug_252_codegen_issue.cs
index 8995721c8..c461c9f96 100644
--- a/src/Persistence/EfCoreTests/Bug_252_codegen_issue.cs
+++ b/src/Persistence/EfCoreTests/Bug_252_codegen_issue.cs
@@ -2,15 +2,12 @@
using JasperFx;
using JasperFx.Core;
using JasperFx.Core.Reflection;
-using Microsoft.Data.SqlClient;
+using JasperFx.Resources;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SharedPersistenceModels.Items;
using Shouldly;
-using Weasel.Core;
-using Weasel.SqlServer;
-using Weasel.SqlServer.Tables;
using Wolverine;
using Wolverine.Attributes;
using Wolverine.EntityFrameworkCore;
@@ -51,21 +48,12 @@ public async Task use_the_saga_type_to_determine_the_correct_DbContext_type()
opt.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString);
opt.UseEntityFrameworkCoreTransactions();
+ opt.UseEntityFrameworkCoreWolverineManagedMigrations();
+ opt.Services.AddResourceSetupOnStartup(StartupAction.ResetState);
opt.Policies.UseDurableLocalQueues();
opt.Policies.AutoApplyTransactions();
}).StartAsync();
- var table = new Table("OrderSagas");
- table.AddColumn("id").AsPrimaryKey();
- table.AddColumn("version");
- await using var conn = new SqlConnection(Servers.SqlServerConnectionString);
- await conn.OpenAsync();
-
- var migration = await SchemaMigration.DetermineAsync(conn, table);
- await new SqlServerMigrator().ApplyAllAsync(conn, migration, AutoCreate.All);
-
- await conn.CloseAsync();
-
await host.InvokeMessageAndWaitAsync(new OrderCreated(Guid.NewGuid()));
}
@@ -79,7 +67,7 @@ public async Task bug_256_message_bus_should_be_in_outbox_transaction()
{
o.UseSqlServer(Servers.SqlServerConnectionString);
});
-
+
opt.Services.AddDbContextWithWolverineIntegration(o =>
{
o.UseSqlServer(Servers.SqlServerConnectionString);
@@ -89,20 +77,12 @@ public async Task bug_256_message_bus_should_be_in_outbox_transaction()
opt.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString);
opt.UseEntityFrameworkCoreTransactions();
+ opt.UseEntityFrameworkCoreWolverineManagedMigrations();
+ opt.Services.AddResourceSetupOnStartup(StartupAction.ResetState);
opt.Policies.UseDurableLocalQueues();
opt.Policies.AutoApplyTransactions();
}).StartAsync();
- var table = new Table("OrderSagas");
- table.AddColumn("id").AsPrimaryKey();
- await using var conn = new SqlConnection(Servers.SqlServerConnectionString);
- await conn.OpenAsync();
-
- var migration = await SchemaMigration.DetermineAsync(conn, table);
- await new SqlServerMigrator().ApplyAllAsync(conn, migration, AutoCreate.All);
-
- await conn.CloseAsync();
-
var chain = host.Services.GetRequiredService().HandlerFor().As().Chain;
var lines = chain.SourceCode.ReadLines();
diff --git a/src/Persistence/EfCoreTests/Optimistic_concurrency_with_ef_core.cs b/src/Persistence/EfCoreTests/Optimistic_concurrency_with_ef_core.cs
index b985ddce2..3a239de4e 100644
--- a/src/Persistence/EfCoreTests/Optimistic_concurrency_with_ef_core.cs
+++ b/src/Persistence/EfCoreTests/Optimistic_concurrency_with_ef_core.cs
@@ -2,15 +2,12 @@
using JasperFx;
using JasperFx.Core;
using JasperFx.Core.Reflection;
-using Microsoft.Data.SqlClient;
+using JasperFx.Resources;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SharedPersistenceModels.Items;
using Shouldly;
-using Weasel.Core;
-using Weasel.SqlServer;
-using Weasel.SqlServer.Tables;
using Wolverine;
using Wolverine.Attributes;
using Wolverine.ComplianceTests;
@@ -47,22 +44,12 @@ public async Task detect_concurrency_exception_as_SagaConcurrencyException()
opt.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString);
opt.UseEntityFrameworkCoreTransactions();
+ opt.UseEntityFrameworkCoreWolverineManagedMigrations();
+ opt.Services.AddResourceSetupOnStartup(StartupAction.ResetState);
opt.Policies.UseDurableLocalQueues();
opt.Policies.AutoApplyTransactions();
}).StartAsync();
- var table = new Table("ConcurrencyTestSagas");
- table.AddColumn("id").AsPrimaryKey();
- table.AddColumn("value");
- table.AddColumn("version");
- await using var conn = new SqlConnection(Servers.SqlServerConnectionString);
- await conn.OpenAsync();
-
- var migration = await SchemaMigration.DetermineAsync(conn, table);
- await new SqlServerMigrator().ApplyAllAsync(conn, migration, AutoCreate.All);
-
- await conn.CloseAsync();
-
using var scope = host.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
diff --git a/src/Persistence/EfCoreTests/Sagas/EfCoreSagaHost.cs b/src/Persistence/EfCoreTests/Sagas/EfCoreSagaHost.cs
index 67a597247..7d4b1c217 100644
--- a/src/Persistence/EfCoreTests/Sagas/EfCoreSagaHost.cs
+++ b/src/Persistence/EfCoreTests/Sagas/EfCoreSagaHost.cs
@@ -1,14 +1,11 @@
using IntegrationTests;
using JasperFx;
-using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using JasperFx.Resources;
using Wolverine.ComplianceTests;
using Wolverine.ComplianceTests.Sagas;
-using Weasel.Core;
-using Weasel.SqlServer;
using Wolverine;
using Wolverine.EntityFrameworkCore;
using Wolverine.SqlServer;
@@ -27,15 +24,15 @@ public IHost BuildHost()
opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString);
- opts.Services.AddDbContext(x => x.UseSqlServer(Servers.SqlServerConnectionString));
+ opts.Services.AddDbContextWithWolverineIntegration(x => x.UseSqlServer(Servers.SqlServerConnectionString));
opts.UseEntityFrameworkCoreTransactions();
+ opts.UseEntityFrameworkCoreWolverineManagedMigrations();
opts.PublishAllMessages().Locally();
});
- // Watch if this hangs, might have to get fancier
- Initialize().GetAwaiter().GetResult();
+ _host.ResetResourceState().GetAwaiter().GetResult();
return _host;
}
@@ -72,24 +69,4 @@ public async Task LoadState(string id) where T : Saga
return await session.FindAsync(id);
}
- public async Task Initialize()
- {
- var tables = new ISchemaObject[]
- {
- new WorkflowStateTable("GuidWorkflowState"),
- new WorkflowStateTable("IntWorkflowState"),
- new WorkflowStateTable("LongWorkflowState"),
- new WorkflowStateTable("StringWorkflowState")
- };
-
- await using var conn = new SqlConnection(Servers.SqlServerConnectionString);
- await conn.OpenAsync();
-
- var migration = await SchemaMigration.DetermineAsync(conn, tables);
- await new SqlServerMigrator().ApplyAllAsync(conn, migration, AutoCreate.All);
-
- await conn.CloseAsync();
-
- await _host.ResetResourceState();
- }
}
\ No newline at end of file
diff --git a/src/Persistence/EfCoreTests/Sagas/SagaDbContext.cs b/src/Persistence/EfCoreTests/Sagas/SagaDbContext.cs
index 166602199..5696c0f45 100644
--- a/src/Persistence/EfCoreTests/Sagas/SagaDbContext.cs
+++ b/src/Persistence/EfCoreTests/Sagas/SagaDbContext.cs
@@ -22,6 +22,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
map.Property(x => x.TwoCompleted).HasColumnName("two");
map.Property(x => x.ThreeCompleted).HasColumnName("three");
map.Property(x => x.FourCompleted).HasColumnName("four");
+ map.Property(x => x.Name).HasColumnName("Name");
});
modelBuilder.Entity(map =>
@@ -32,6 +33,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
map.Property(x => x.TwoCompleted).HasColumnName("two");
map.Property(x => x.ThreeCompleted).HasColumnName("three");
map.Property(x => x.FourCompleted).HasColumnName("four");
+ map.Property(x => x.Name).HasColumnName("Name");
});
modelBuilder.Entity(map =>
@@ -42,6 +44,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
map.Property(x => x.TwoCompleted).HasColumnName("two");
map.Property(x => x.ThreeCompleted).HasColumnName("three");
map.Property(x => x.FourCompleted).HasColumnName("four");
+ map.Property(x => x.Name).HasColumnName("Name");
});
modelBuilder.Entity(map =>
@@ -52,6 +55,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
map.Property(x => x.TwoCompleted).HasColumnName("two");
map.Property(x => x.ThreeCompleted).HasColumnName("three");
map.Property(x => x.FourCompleted).HasColumnName("four");
+ map.Property(x => x.Name).HasColumnName("Name");
});
}
}
\ No newline at end of file
diff --git a/src/Persistence/EfCoreTests/Sagas/WorkflowStateTable.cs b/src/Persistence/EfCoreTests/Sagas/WorkflowStateTable.cs
deleted file mode 100644
index 19e6ad56c..000000000
--- a/src/Persistence/EfCoreTests/Sagas/WorkflowStateTable.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using Weasel.SqlServer.Tables;
-
-namespace EfCoreTests.Sagas;
-
-internal class WorkflowStateTable : Table
-{
- public WorkflowStateTable(string tableName) : base(tableName)
- {
- AddColumn("Id").AsPrimaryKey();
- AddColumn("one");
- AddColumn("two");
- AddColumn("three");
- AddColumn("four");
- AddColumn("Name");
- AddColumn("version");
- }
-}
\ No newline at end of file
diff --git a/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs b/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs
index d4429cbb4..2d2980624 100644
--- a/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs
+++ b/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs
@@ -9,9 +9,6 @@
using Microsoft.Extensions.Hosting;
using SharedPersistenceModels.Items;
using Shouldly;
-using Weasel.Core;
-using Weasel.SqlServer;
-using Weasel.SqlServer.Tables;
using Wolverine;
using Wolverine.EntityFrameworkCore;
using Wolverine.Persistence;
@@ -23,29 +20,9 @@ namespace EfCoreTests;
public class idempotency_with_inline_or_buffered_endpoints_end_to_end : IAsyncLifetime
{
- public async Task InitializeAsync()
+ public Task InitializeAsync()
{
- await buildSqlServer();
- }
-
- private static async Task buildSqlServer()
- {
- var itemsTable = new Table(new DbObjectName("dbo", "items"));
- itemsTable.AddColumn("Id").AsPrimaryKey();
- itemsTable.AddColumn("Name");
- itemsTable.AddColumn("Approved");
-
- await using var conn = new SqlConnection(Servers.SqlServerConnectionString);
- await conn.OpenAsync();
- var migration = await SchemaMigration.DetermineAsync(conn, itemsTable);
- if (migration.Difference != SchemaPatchDifference.None)
- {
- var sqlServerMigrator = new SqlServerMigrator();
-
- await sqlServerMigrator.ApplyAllAsync(conn, migration, AutoCreate.CreateOrUpdate);
- }
-
- await conn.CloseAsync();
+ return Task.CompletedTask;
}
public Task DisposeAsync()
@@ -81,6 +58,7 @@ public async Task happy_and_sad_path(IdempotencyStyle idempotency, bool isWolver
opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "idempotency");
opts.UseEntityFrameworkCoreTransactions();
+ opts.UseEntityFrameworkCoreWolverineManagedMigrations();
}).StartAsync();
var messageId = Guid.NewGuid();
@@ -131,6 +109,7 @@ public async Task happy_and_sad_path_with_message_and_destination_tracking(Idemp
opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "idempotency");
opts.UseEntityFrameworkCoreTransactions();
+ opts.UseEntityFrameworkCoreWolverineManagedMigrations();
}).StartAsync();
var messageId = Guid.NewGuid();
@@ -173,6 +152,7 @@ public async Task apply_idempotency_to_non_transactional_handler()
opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "idempotency");
opts.UseEntityFrameworkCoreTransactions();
+ opts.UseEntityFrameworkCoreWolverineManagedMigrations();
// THIS RIGHT HERE
opts.Policies.AutoApplyIdempotencyOnNonTransactionalHandlers();
diff --git a/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs b/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs
index f78be6215..5505dfc50 100644
--- a/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs
+++ b/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs
@@ -1,9 +1,11 @@
using JasperFx;
using JasperFx.CodeGeneration;
using JasperFx.CodeGeneration.Model;
+using JasperFx.Events.Aggregation;
using Marten.Schema;
using NSubstitute;
using Shouldly;
+using StronglyTypedIds;
using Wolverine.Configuration;
using Wolverine.Marten;
using Wolverine.Runtime;
@@ -97,6 +99,43 @@ public void cannot_determine_aggregate_id()
AggregateHandling.DetermineAggregateIdMember(typeof(Invoice), typeof(BadCommand));
});
}
+
+ [Fact]
+ public void determine_aggregate_id_by_strong_typed_id_on_aggregate_id_property()
+ {
+ // CourseAggregate has CourseId Id property (strong typed ID)
+ // ChangeCourseCapacity has a single CourseId property
+ AggregateHandling.DetermineAggregateIdMember(typeof(CourseAggregate), typeof(ChangeCourseCapacity))
+ .Name.ShouldBe(nameof(ChangeCourseCapacity.CourseId));
+ }
+
+ [Fact]
+ public void determine_aggregate_id_by_identified_by_interface()
+ {
+ // CourseAggregateWithInterface implements IdentifiedBy
+ // but uses Guid Id property. The IdentifiedBy signals the strong typed ID.
+ AggregateHandling.DetermineAggregateIdMember(typeof(CourseAggregateWithInterface), typeof(ChangeCourseCapacityForInterface))
+ .Name.ShouldBe(nameof(ChangeCourseCapacityForInterface.CourseId));
+ }
+
+ [Fact]
+ public void strong_typed_id_matching_requires_single_property()
+ {
+ // If the command has multiple properties of the same strong typed ID type,
+ // the fallback should NOT match and should throw
+ Should.Throw(() =>
+ {
+ AggregateHandling.DetermineAggregateIdMember(typeof(CourseAggregate), typeof(TransferBetweenCourses));
+ });
+ }
+
+ [Fact]
+ public void strong_typed_id_not_used_when_conventional_name_matches()
+ {
+ // Even with a strong typed ID, if the conventional name "Id" matches, use that
+ AggregateHandling.DetermineAggregateIdMember(typeof(CourseAggregate), typeof(UpdateCourseWithConventionalId))
+ .Name.ShouldBe(nameof(UpdateCourseWithConventionalId.Id));
+ }
}
public class Invoice
@@ -148,4 +187,43 @@ public Task Handle(Invalid2 command, Invoice invoice)
public record Invalid1(Guid InvoiceId);
-public record Invalid2(Guid InvoiceId);
\ No newline at end of file
+public record Invalid2(Guid InvoiceId);
+
+// Strong typed ID using StronglyTypedIds package
+[StronglyTypedId(Template.Guid)]
+public readonly partial struct CourseId;
+
+// Aggregate with a strong typed ID property
+public class CourseAggregate
+{
+ public CourseId Id { get; set; }
+ public int Capacity { get; set; }
+ public int Version { get; set; }
+
+ public void Apply(CourseCapacityChanged e)
+ {
+ Capacity = e.NewCapacity;
+ }
+}
+
+// Aggregate that uses IdentifiedBy to declare its strong typed identity
+public class CourseAggregateWithInterface : IdentifiedBy
+{
+ public Guid Id { get; set; }
+ public int Capacity { get; set; }
+ public int Version { get; set; }
+}
+
+public record CourseCapacityChanged(int NewCapacity);
+
+// Command with a single CourseId property (non-conventional name)
+public record ChangeCourseCapacity(CourseId CourseId, int NewCapacity);
+
+// Command for the IdentifiedBy aggregate
+public record ChangeCourseCapacityForInterface(CourseId CourseId, int NewCapacity);
+
+// Command with multiple CourseId properties — should NOT match
+public record TransferBetweenCourses(CourseId SourceCourseId, CourseId TargetCourseId);
+
+// Command with conventional "Id" name — should use conventional matching, not fallback
+public record UpdateCourseWithConventionalId(CourseId Id, string Name);
\ No newline at end of file
diff --git a/src/Persistence/MartenTests/AggregateHandlerWorkflow/always_enforce_consistency_workflow.cs b/src/Persistence/MartenTests/AggregateHandlerWorkflow/always_enforce_consistency_workflow.cs
new file mode 100644
index 000000000..7e3afaead
--- /dev/null
+++ b/src/Persistence/MartenTests/AggregateHandlerWorkflow/always_enforce_consistency_workflow.cs
@@ -0,0 +1,263 @@
+using IntegrationTests;
+using JasperFx;
+using JasperFx.CodeGeneration;
+using JasperFx.Resources;
+using Marten;
+using Marten.Events;
+using Marten.Events.Projections;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Wolverine;
+using Wolverine.Marten;
+using Wolverine.Tracking;
+
+namespace MartenTests.AggregateHandlerWorkflow;
+
+public class always_enforce_consistency_workflow : PostgresqlContext, IAsyncLifetime
+{
+ private IHost theHost;
+ private IDocumentStore theStore;
+ private Guid theStreamId;
+
+ public async Task InitializeAsync()
+ {
+ theHost = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Services.AddMarten(m =>
+ {
+ m.Connection(Servers.PostgresConnectionString);
+ m.Projections.Snapshot(SnapshotLifecycle.Inline);
+ m.DisableNpgsqlLogging = true;
+ })
+ .UseLightweightSessions()
+ .IntegrateWithWolverine();
+
+ opts.Services.AddResourceSetupOnStartup();
+ opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Auto;
+ }).StartAsync();
+
+ theStore = theHost.Services.GetRequiredService();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await theHost.StopAsync();
+ theHost.Dispose();
+ }
+
+ private async Task GivenAggregate()
+ {
+ await using var session = theStore.LightweightSession();
+ var action = session.Events.StartStream(new ConsistencyStarted());
+ await session.SaveChangesAsync();
+
+ theStreamId = action.Id;
+ }
+
+ private async Task LoadAggregate()
+ {
+ await using var session = theStore.LightweightSession();
+ return await session.LoadAsync(theStreamId);
+ }
+
+ [Fact]
+ public async Task happy_path_with_events_using_attribute_property()
+ {
+ await GivenAggregate();
+ await theHost.InvokeMessageAndWaitAsync(new ConsistentIncrementA(theStreamId));
+
+ var aggregate = await LoadAggregate();
+ aggregate.ACount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task happy_path_no_events_emitted_using_attribute_property()
+ {
+ await GivenAggregate();
+
+ // This should succeed even though no events are emitted,
+ // because no one else has modified the stream
+ await theHost.InvokeMessageAndWaitAsync(new ConsistentDoNothing(theStreamId));
+
+ var aggregate = await LoadAggregate();
+ aggregate.ACount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task concurrency_violation_no_events_emitted_using_attribute_property()
+ {
+ await GivenAggregate();
+
+ // The handler will sneakily modify the stream mid-flight using a separate session,
+ // simulating a concurrent writer between FetchForWriting and SaveChangesAsync
+ await Should.ThrowAsync(
+ theHost.InvokeMessageAndWaitAsync(
+ new ConsistentDoNothingWithConcurrentModification(theStreamId)));
+ }
+
+ [Fact]
+ public async Task happy_path_with_events_using_consistent_aggregate_handler_attribute()
+ {
+ await GivenAggregate();
+ await theHost.InvokeMessageAndWaitAsync(new ConsistentHandlerIncrementA(theStreamId));
+
+ var aggregate = await LoadAggregate();
+ aggregate.ACount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task happy_path_no_events_using_consistent_aggregate_handler_attribute()
+ {
+ await GivenAggregate();
+
+ await theHost.InvokeMessageAndWaitAsync(new ConsistentHandlerDoNothing(theStreamId));
+
+ var aggregate = await LoadAggregate();
+ aggregate.ACount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task concurrency_violation_no_events_using_consistent_aggregate_handler_attribute()
+ {
+ await GivenAggregate();
+
+ await Should.ThrowAsync(
+ theHost.InvokeMessageAndWaitAsync(
+ new ConsistentHandlerDoNothingWithConcurrentModification(theStreamId)));
+ }
+
+ [Fact]
+ public async Task happy_path_using_parameter_level_consistent_aggregate_attribute()
+ {
+ await GivenAggregate();
+ await theHost.InvokeMessageAndWaitAsync(new ConsistentParamIncrementA(theStreamId));
+
+ var aggregate = await LoadAggregate();
+ aggregate.ACount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task concurrency_violation_using_parameter_level_consistent_aggregate_attribute()
+ {
+ await GivenAggregate();
+
+ await Should.ThrowAsync(
+ theHost.InvokeMessageAndWaitAsync(
+ new ConsistentParamDoNothingWithConcurrentModification(theStreamId)));
+ }
+}
+
+#region Aggregate and Events
+
+public class ConsistencyAggregate
+{
+ public ConsistencyAggregate()
+ {
+ }
+
+ public ConsistencyAggregate(ConsistencyStarted started)
+ {
+ }
+
+ public Guid Id { get; set; }
+ public int ACount { get; set; }
+
+ public void Apply(ConsistencyAEvent e) => ACount++;
+}
+
+public record ConsistencyStarted;
+public record ConsistencyAEvent;
+
+#endregion
+
+#region Commands
+
+// Happy path commands
+public record ConsistentIncrementA(Guid ConsistencyAggregateId);
+public record ConsistentDoNothing(Guid ConsistencyAggregateId);
+public record ConsistentHandlerIncrementA(Guid ConsistencyAggregateId);
+public record ConsistentHandlerDoNothing(Guid ConsistencyAggregateId);
+public record ConsistentParamIncrementA(Guid ConsistencyAggregateId);
+
+// Concurrency violation commands - handlers will sneakily modify the stream mid-flight
+public record ConsistentDoNothingWithConcurrentModification(Guid ConsistencyAggregateId);
+public record ConsistentHandlerDoNothingWithConcurrentModification(Guid ConsistencyAggregateId);
+public record ConsistentParamDoNothingWithConcurrentModification(Guid ConsistencyAggregateId);
+
+#endregion
+
+#region Handlers using AggregateHandler with AlwaysEnforceConsistency property
+
+[AggregateHandler(AlwaysEnforceConsistency = true)]
+public static class ConsistentPropertyHandler
+{
+ public static ConsistencyAEvent Handle(ConsistentIncrementA command, ConsistencyAggregate aggregate)
+ {
+ return new ConsistencyAEvent();
+ }
+
+ public static void Handle(ConsistentDoNothing command, IEventStream stream)
+ {
+ // Intentionally do not append any events
+ }
+
+ public static async Task Handle(ConsistentDoNothingWithConcurrentModification command,
+ IEventStream stream, IDocumentStore store)
+ {
+ // Simulate a concurrent writer modifying the stream between FetchForWriting and SaveChangesAsync
+ await using var sneakySession = store.LightweightSession();
+ sneakySession.Events.Append(command.ConsistencyAggregateId, new ConsistencyAEvent());
+ await sneakySession.SaveChangesAsync();
+ }
+}
+
+#endregion
+
+#region Handlers using ConsistentAggregateHandler attribute
+
+[ConsistentAggregateHandler]
+public static class ConsistentAggregateHandlerUsage
+{
+ public static ConsistencyAEvent Handle(ConsistentHandlerIncrementA command, ConsistencyAggregate aggregate)
+ {
+ return new ConsistencyAEvent();
+ }
+
+ public static void Handle(ConsistentHandlerDoNothing command, IEventStream stream)
+ {
+ // Intentionally do not append any events
+ }
+
+ public static async Task Handle(ConsistentHandlerDoNothingWithConcurrentModification command,
+ IEventStream stream, IDocumentStore store)
+ {
+ await using var sneakySession = store.LightweightSession();
+ sneakySession.Events.Append(command.ConsistencyAggregateId, new ConsistencyAEvent());
+ await sneakySession.SaveChangesAsync();
+ }
+}
+
+#endregion
+
+#region Handlers using parameter-level ConsistentAggregate attribute
+
+public static class ConsistentParamHandler
+{
+ public static ConsistencyAEvent Handle(ConsistentParamIncrementA command,
+ [ConsistentAggregate] ConsistencyAggregate aggregate)
+ {
+ return new ConsistencyAEvent();
+ }
+
+ public static async Task Handle(ConsistentParamDoNothingWithConcurrentModification command,
+ [ConsistentAggregate] IEventStream stream, IDocumentStore store)
+ {
+ await using var sneakySession = store.LightweightSession();
+ sneakySession.Events.Append(stream.Id, new ConsistencyAEvent());
+ await sneakySession.SaveChangesAsync();
+ }
+}
+
+#endregion
diff --git a/src/Persistence/MartenTests/AggregateHandlerWorkflow/multi_stream_version_and_consistency.cs b/src/Persistence/MartenTests/AggregateHandlerWorkflow/multi_stream_version_and_consistency.cs
new file mode 100644
index 000000000..dc993b910
--- /dev/null
+++ b/src/Persistence/MartenTests/AggregateHandlerWorkflow/multi_stream_version_and_consistency.cs
@@ -0,0 +1,278 @@
+using IntegrationTests;
+using JasperFx;
+using JasperFx.CodeGeneration;
+using JasperFx.Resources;
+using Marten;
+using Marten.Events;
+using Marten.Events.Projections;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Wolverine;
+using Wolverine.Marten;
+using Wolverine.Tracking;
+
+namespace MartenTests.AggregateHandlerWorkflow;
+
+public class multi_stream_version_and_consistency : PostgresqlContext, IAsyncLifetime
+{
+ private IHost theHost;
+ private IDocumentStore theStore;
+ private Guid fromAccountId;
+ private Guid toAccountId;
+
+ public async Task InitializeAsync()
+ {
+ theHost = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Services.AddMarten(m =>
+ {
+ m.Connection(Servers.PostgresConnectionString);
+ m.DatabaseSchemaName = "multi_stream_tests";
+ m.Projections.Snapshot(SnapshotLifecycle.Inline);
+ m.DisableNpgsqlLogging = true;
+ })
+ .UseLightweightSessions()
+ .IntegrateWithWolverine();
+
+ opts.Services.AddResourceSetupOnStartup();
+ opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Auto;
+ }).StartAsync();
+
+ theStore = theHost.Services.GetRequiredService();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await theHost.StopAsync();
+ theHost.Dispose();
+ }
+
+ private async Task GivenAccounts(decimal fromBalance = 1000, decimal toBalance = 500)
+ {
+ await using var session = theStore.LightweightSession();
+ fromAccountId = session.Events.StartStream(new BankAccountOpened(fromBalance)).Id;
+ toAccountId = session.Events.StartStream(new BankAccountOpened(toBalance)).Id;
+ await session.SaveChangesAsync();
+ }
+
+ private async Task LoadAccount(Guid id)
+ {
+ await using var session = theStore.LightweightSession();
+ return await session.LoadAsync(id);
+ }
+
+ [Fact]
+ public async Task only_first_stream_gets_version_check_by_default()
+ {
+ await GivenAccounts();
+
+ // The command has a "Version" property. Only the first [WriteAggregate]
+ // (fromAccount) should pick it up. The second (toAccount) should NOT.
+ // Version = 1 matches the "from" account after the open event.
+ await theHost.InvokeMessageAndWaitAsync(
+ new TransferFunds(fromAccountId, toAccountId, 100, Version: 1));
+
+ var from = await LoadAccount(fromAccountId);
+ var to = await LoadAccount(toAccountId);
+ from.Balance.ShouldBe(900);
+ to.Balance.ShouldBe(600);
+ }
+
+ [Fact]
+ public async Task wrong_version_on_first_stream_causes_concurrency_error()
+ {
+ await GivenAccounts();
+
+ // Version = 99 doesn't match the "from" account
+ await Should.ThrowAsync(
+ theHost.InvokeMessageAndWaitAsync(
+ new TransferFunds(fromAccountId, toAccountId, 100, Version: 99)));
+ }
+
+ [Fact]
+ public async Task explicit_version_source_on_secondary_stream()
+ {
+ await GivenAccounts();
+
+ // Both FromVersion and ToVersion = 1 match their respective streams
+ await theHost.InvokeMessageAndWaitAsync(
+ new TransferFundsWithDualVersion(fromAccountId, toAccountId, 100,
+ FromVersion: 1, ToVersion: 1));
+
+ var from = await LoadAccount(fromAccountId);
+ var to = await LoadAccount(toAccountId);
+ from.Balance.ShouldBe(900);
+ to.Balance.ShouldBe(600);
+ }
+
+ [Fact]
+ public async Task wrong_version_on_explicitly_sourced_secondary_stream()
+ {
+ await GivenAccounts();
+
+ // FromVersion is correct, but ToVersion is wrong
+ await Should.ThrowAsync(
+ theHost.InvokeMessageAndWaitAsync(
+ new TransferFundsWithDualVersion(fromAccountId, toAccountId, 100,
+ FromVersion: 1, ToVersion: 99)));
+ }
+
+ [Fact]
+ public async Task always_enforce_consistency_on_secondary_stream_with_no_events()
+ {
+ await GivenAccounts(fromBalance: 0);
+
+ // Insufficient funds: "from" stream gets no events, but has AlwaysEnforceConsistency.
+ // A concurrent modification sneaks in during handling.
+ await Should.ThrowAsync(
+ theHost.InvokeMessageAndWaitAsync(
+ new TransferWithConsistencyCheck(fromAccountId, toAccountId, 100)));
+ }
+
+ [Fact]
+ public async Task always_enforce_consistency_happy_path_no_events()
+ {
+ await GivenAccounts(fromBalance: 0);
+
+ // No concurrent modification, so this should succeed even though
+ // no events are appended to the "from" stream
+ await theHost.InvokeMessageAndWaitAsync(
+ new TransferWithConsistencyCheckNoConcurrentModification(fromAccountId, toAccountId, 100));
+
+ // Balances should be unchanged since insufficient funds
+ var from = await LoadAccount(fromAccountId);
+ var to = await LoadAccount(toAccountId);
+ from.Balance.ShouldBe(0);
+ to.Balance.ShouldBe(500);
+ }
+}
+
+#region Aggregate and Events
+
+public class BankAccount
+{
+ public Guid Id { get; set; }
+ public decimal Balance { get; set; }
+
+ public static BankAccount Create(BankAccountOpened opened) =>
+ new() { Balance = opened.InitialBalance };
+
+ public void Apply(FundsWithdrawn e) => Balance -= e.Amount;
+ public void Apply(FundsDeposited e) => Balance += e.Amount;
+}
+
+public record BankAccountOpened(decimal InitialBalance);
+public record FundsWithdrawn(decimal Amount);
+public record FundsDeposited(decimal Amount);
+
+#endregion
+
+#region Commands
+
+// Default version convention: only the first [WriteAggregate] picks up "Version"
+public record TransferFunds(Guid BankAccountId, Guid ToAccountId, decimal Amount, long Version);
+
+// Explicit VersionSource on both streams
+public record TransferFundsWithDualVersion(
+ Guid FromAccountId, Guid ToAccountId, decimal Amount,
+ long FromVersion, long ToVersion);
+
+// Multi-stream with AlwaysEnforceConsistency and concurrent modification
+public record TransferWithConsistencyCheck(Guid FromAccountId, Guid ToAccountId, decimal Amount);
+
+// Multi-stream with AlwaysEnforceConsistency, no concurrent modification
+public record TransferWithConsistencyCheckNoConcurrentModification(
+ Guid FromAccountId, Guid ToAccountId, decimal Amount);
+
+#endregion
+
+#region Handlers
+
+// Default behavior: first [WriteAggregate] picks up "Version", second does not
+public static class TransferFundsHandler
+{
+ public static void Handle(
+ TransferFunds command,
+ [WriteAggregate] IEventStream fromAccount,
+ [WriteAggregate(nameof(TransferFunds.ToAccountId))] IEventStream toAccount)
+ {
+ if (fromAccount.Aggregate.Balance >= command.Amount)
+ {
+ fromAccount.AppendOne(new FundsWithdrawn(command.Amount));
+ toAccount.AppendOne(new FundsDeposited(command.Amount));
+ }
+ }
+}
+
+// Explicit VersionSource on both streams
+public static class TransferFundsWithDualVersionHandler
+{
+ public static void Handle(
+ TransferFundsWithDualVersion command,
+ [WriteAggregate(nameof(TransferFundsWithDualVersion.FromAccountId),
+ VersionSource = nameof(TransferFundsWithDualVersion.FromVersion))]
+ IEventStream fromAccount,
+ [WriteAggregate(nameof(TransferFundsWithDualVersion.ToAccountId),
+ VersionSource = nameof(TransferFundsWithDualVersion.ToVersion))]
+ IEventStream toAccount)
+ {
+ if (fromAccount.Aggregate.Balance >= command.Amount)
+ {
+ fromAccount.AppendOne(new FundsWithdrawn(command.Amount));
+ toAccount.AppendOne(new FundsDeposited(command.Amount));
+ }
+ }
+}
+
+// AlwaysEnforceConsistency on "from" stream, with sneaky concurrent modification
+public static class TransferWithConsistencyCheckHandler
+{
+ public static async Task Handle(
+ TransferWithConsistencyCheck command,
+ [WriteAggregate(nameof(TransferWithConsistencyCheck.FromAccountId),
+ AlwaysEnforceConsistency = true)]
+ IEventStream fromAccount,
+ [WriteAggregate(nameof(TransferWithConsistencyCheck.ToAccountId))]
+ IEventStream toAccount,
+ IDocumentStore store)
+ {
+ // Sneaky concurrent modification on the "from" account
+ await using var sneakySession = store.LightweightSession();
+ sneakySession.Events.Append(command.FromAccountId, new FundsDeposited(1));
+ await sneakySession.SaveChangesAsync();
+
+ // Insufficient funds: don't append any events to "from"
+ if (fromAccount.Aggregate.Balance >= command.Amount)
+ {
+ fromAccount.AppendOne(new FundsWithdrawn(command.Amount));
+ toAccount.AppendOne(new FundsDeposited(command.Amount));
+ }
+ // Even though no events appended to "from", AlwaysEnforceConsistency
+ // should detect the concurrent modification and throw
+ }
+}
+
+// AlwaysEnforceConsistency on "from" stream, NO concurrent modification
+public static class TransferWithConsistencyCheckNoConcurrentModificationHandler
+{
+ public static void Handle(
+ TransferWithConsistencyCheckNoConcurrentModification command,
+ [WriteAggregate(nameof(TransferWithConsistencyCheckNoConcurrentModification.FromAccountId),
+ AlwaysEnforceConsistency = true)]
+ IEventStream fromAccount,
+ [WriteAggregate(nameof(TransferWithConsistencyCheckNoConcurrentModification.ToAccountId))]
+ IEventStream toAccount)
+ {
+ // Insufficient funds: don't append any events to either stream
+ if (fromAccount.Aggregate.Balance >= command.Amount)
+ {
+ fromAccount.AppendOne(new FundsWithdrawn(command.Amount));
+ toAccount.AppendOne(new FundsDeposited(command.Amount));
+ }
+ // No concurrent modification, so AlwaysEnforceConsistency should pass
+ }
+}
+
+#endregion
diff --git a/src/Persistence/MartenTests/AggregateHandlerWorkflow/natural_key_aggregate_handler_workflow.cs b/src/Persistence/MartenTests/AggregateHandlerWorkflow/natural_key_aggregate_handler_workflow.cs
new file mode 100644
index 000000000..c024743a6
--- /dev/null
+++ b/src/Persistence/MartenTests/AggregateHandlerWorkflow/natural_key_aggregate_handler_workflow.cs
@@ -0,0 +1,191 @@
+using IntegrationTests;
+using JasperFx;
+using JasperFx.CodeGeneration;
+using JasperFx.Events.Aggregation;
+using JasperFx.Resources;
+using Marten;
+using Marten.Events;
+using Marten.Events.Projections;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Wolverine;
+using Wolverine.ComplianceTests;
+using Wolverine.Marten;
+using Wolverine.Tracking;
+
+namespace MartenTests.AggregateHandlerWorkflow;
+
+public class natural_key_aggregate_handler_workflow : PostgresqlContext, IAsyncDisposable
+{
+ private readonly IHost _host;
+ private readonly IDocumentStore _store;
+
+ public natural_key_aggregate_handler_workflow()
+ {
+ _host = WolverineHost.For(opts =>
+ {
+ opts.Services.AddMarten(m =>
+ {
+ m.Connection(Servers.PostgresConnectionString);
+ m.DatabaseSchemaName = "nk_handler";
+ m.Projections.Snapshot(SnapshotLifecycle.Inline);
+ })
+ .UseLightweightSessions()
+ .IntegrateWithWolverine();
+
+ opts.Services.AddResourceSetupOnStartup();
+ opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Auto;
+ });
+
+ _store = _host.Services.GetRequiredService();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _host.StopAsync();
+ _host.Dispose();
+ }
+
+ [Fact]
+ public async Task handle_command_with_natural_key_returning_single_event()
+ {
+ var streamId = Guid.NewGuid();
+ var orderNumber = new NkHandlerOrderNumber("ORD-WOL-001");
+
+ await using var session = _store.LightweightSession();
+ session.Events.StartStream(streamId,
+ new NkHandlerOrderCreated(orderNumber, "Alice"));
+ await session.SaveChangesAsync();
+
+ await _host.TrackActivity()
+ .SendMessageAndWaitAsync(new AddNkOrderItem(orderNumber, "Widget", 9.99m));
+
+ await using var verify = _store.LightweightSession();
+ var aggregate = await verify.LoadAsync(streamId);
+
+ aggregate.ShouldNotBeNull();
+ aggregate!.TotalAmount.ShouldBe(9.99m);
+ aggregate.CustomerName.ShouldBe("Alice");
+ }
+
+ [Fact]
+ public async Task handle_command_with_natural_key_returning_multiple_events()
+ {
+ var streamId = Guid.NewGuid();
+ var orderNumber = new NkHandlerOrderNumber("ORD-WOL-002");
+
+ await using var session = _store.LightweightSession();
+ session.Events.StartStream(streamId,
+ new NkHandlerOrderCreated(orderNumber, "Bob"));
+ await session.SaveChangesAsync();
+
+ await _host.TrackActivity()
+ .SendMessageAndWaitAsync(new AddNkOrderItems(orderNumber,
+ [("Gadget", 19.99m), ("Doohickey", 5.50m)]));
+
+ await using var verify = _store.LightweightSession();
+ var aggregate = await verify.LoadAsync(streamId);
+
+ aggregate.ShouldNotBeNull();
+ aggregate!.TotalAmount.ShouldBe(25.49m);
+ }
+
+ [Fact]
+ public async Task handle_command_with_natural_key_using_event_stream()
+ {
+ var streamId = Guid.NewGuid();
+ var orderNumber = new NkHandlerOrderNumber("ORD-WOL-003");
+
+ await using var session = _store.LightweightSession();
+ session.Events.StartStream(streamId,
+ new NkHandlerOrderCreated(orderNumber, "Charlie"),
+ new NkHandlerItemAdded("Widget", 10.00m));
+ await session.SaveChangesAsync();
+
+ await _host.TrackActivity()
+ .SendMessageAndWaitAsync(new CompleteNkOrder(orderNumber));
+
+ await using var verify = _store.LightweightSession();
+ var aggregate = await verify.LoadAsync(streamId);
+
+ aggregate.ShouldNotBeNull();
+ aggregate!.IsComplete.ShouldBeTrue();
+ aggregate.TotalAmount.ShouldBe(10.00m);
+ }
+}
+
+#region sample_wolverine_marten_natural_key_aggregate
+
+public record NkHandlerOrderNumber(string Value);
+
+public class NkOrderAggregate
+{
+ public Guid Id { get; set; }
+
+ [NaturalKey]
+ public NkHandlerOrderNumber OrderNum { get; set; } = null!;
+
+ public decimal TotalAmount { get; set; }
+ public string CustomerName { get; set; } = string.Empty;
+ public bool IsComplete { get; set; }
+
+ [NaturalKeySource]
+ public void Apply(NkHandlerOrderCreated e)
+ {
+ OrderNum = e.OrderNumber;
+ CustomerName = e.CustomerName;
+ }
+
+ public void Apply(NkHandlerItemAdded e)
+ {
+ TotalAmount += e.Price;
+ }
+
+ public void Apply(NkHandlerOrderCompleted e)
+ {
+ IsComplete = true;
+ }
+}
+
+public record NkHandlerOrderCreated(NkHandlerOrderNumber OrderNumber, string CustomerName);
+public record NkHandlerItemAdded(string ItemName, decimal Price);
+public record NkHandlerOrderCompleted;
+
+#endregion
+
+#region sample_wolverine_marten_natural_key_commands
+
+public record AddNkOrderItem(NkHandlerOrderNumber OrderNum, string ItemName, decimal Price);
+public record AddNkOrderItems(NkHandlerOrderNumber OrderNum, (string Name, decimal Price)[] Items);
+public record CompleteNkOrder(NkHandlerOrderNumber OrderNum);
+
+#endregion
+
+#region sample_wolverine_marten_natural_key_handlers
+
+public static class NkOrderHandler
+{
+ public static NkHandlerItemAdded Handle(AddNkOrderItem command,
+ [WriteAggregate] NkOrderAggregate aggregate)
+ {
+ return new NkHandlerItemAdded(command.ItemName, command.Price);
+ }
+
+ public static IEnumerable Handle(AddNkOrderItems command,
+ [WriteAggregate] NkOrderAggregate aggregate)
+ {
+ foreach (var (name, price) in command.Items)
+ {
+ yield return new NkHandlerItemAdded(name, price);
+ }
+ }
+
+ public static void Handle(CompleteNkOrder command,
+ [WriteAggregate] IEventStream stream)
+ {
+ stream.AppendOne(new NkHandlerOrderCompleted());
+ }
+}
+
+#endregion
diff --git a/src/Persistence/MartenTests/AggregateHandlerWorkflow/version_source_override.cs b/src/Persistence/MartenTests/AggregateHandlerWorkflow/version_source_override.cs
new file mode 100644
index 000000000..1e2d8de9b
--- /dev/null
+++ b/src/Persistence/MartenTests/AggregateHandlerWorkflow/version_source_override.cs
@@ -0,0 +1,167 @@
+using IntegrationTests;
+using JasperFx;
+using JasperFx.CodeGeneration;
+using JasperFx.Resources;
+using Marten;
+using Marten.Events;
+using Marten.Events.Projections;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Wolverine;
+using Wolverine.Marten;
+using Wolverine.Tracking;
+
+namespace MartenTests.AggregateHandlerWorkflow;
+
+public class version_source_override : PostgresqlContext, IAsyncLifetime
+{
+ private IHost theHost;
+ private IDocumentStore theStore;
+ private Guid theStreamId;
+
+ public async Task InitializeAsync()
+ {
+ theHost = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Services.AddMarten(m =>
+ {
+ m.Connection(Servers.PostgresConnectionString);
+ m.Projections.Snapshot(SnapshotLifecycle.Inline);
+ m.DisableNpgsqlLogging = true;
+ })
+ .UseLightweightSessions()
+ .IntegrateWithWolverine();
+
+ opts.Services.AddResourceSetupOnStartup();
+ opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Auto;
+ }).StartAsync();
+
+ theStore = theHost.Services.GetRequiredService();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await theHost.StopAsync();
+ theHost.Dispose();
+ }
+
+ private async Task GivenAggregate()
+ {
+ await using var session = theStore.LightweightSession();
+ var action = session.Events.StartStream(new VersionSourceStarted());
+ await session.SaveChangesAsync();
+
+ theStreamId = action.Id;
+ }
+
+ private async Task LoadAggregate()
+ {
+ await using var session = theStore.LightweightSession();
+ return await session.LoadAsync(theStreamId);
+ }
+
+ [Fact]
+ public async Task happy_path_with_custom_version_source_on_aggregate_handler()
+ {
+ await GivenAggregate();
+
+ // ExpectedVersion = 1 matches the stream version after the start event
+ await theHost.InvokeMessageAndWaitAsync(
+ new IncrementWithCustomVersion(theStreamId, ExpectedVersion: 1));
+
+ var aggregate = await LoadAggregate();
+ aggregate.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task wrong_version_with_custom_version_source_on_aggregate_handler()
+ {
+ await GivenAggregate();
+
+ // ExpectedVersion = 99 does not match the actual stream version of 1
+ await Should.ThrowAsync(
+ theHost.InvokeMessageAndWaitAsync(
+ new IncrementWithCustomVersion(theStreamId, ExpectedVersion: 99)));
+ }
+
+ [Fact]
+ public async Task happy_path_with_custom_version_source_on_write_aggregate()
+ {
+ await GivenAggregate();
+
+ await theHost.InvokeMessageAndWaitAsync(
+ new IncrementWithParamVersionSource(theStreamId, MyVersion: 1));
+
+ var aggregate = await LoadAggregate();
+ aggregate.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task wrong_version_with_custom_version_source_on_write_aggregate()
+ {
+ await GivenAggregate();
+
+ await Should.ThrowAsync(
+ theHost.InvokeMessageAndWaitAsync(
+ new IncrementWithParamVersionSource(theStreamId, MyVersion: 99)));
+ }
+}
+
+#region Types
+
+public class VersionSourceAggregate
+{
+ public VersionSourceAggregate()
+ {
+ }
+
+ public VersionSourceAggregate(VersionSourceStarted started)
+ {
+ }
+
+ public Guid Id { get; set; }
+ public int Count { get; set; }
+
+ public void Apply(VersionSourceIncremented e) => Count++;
+}
+
+public record VersionSourceStarted;
+public record VersionSourceIncremented;
+
+#endregion
+
+#region Commands
+
+// Command with a non-standard version property name for AggregateHandler usage
+public record IncrementWithCustomVersion(Guid VersionSourceAggregateId, long ExpectedVersion);
+
+// Command with a non-standard version property name for WriteAggregate parameter usage
+public record IncrementWithParamVersionSource(Guid VersionSourceAggregateId, long MyVersion);
+
+#endregion
+
+#region Handlers
+
+[AggregateHandler(VersionSource = nameof(IncrementWithCustomVersion.ExpectedVersion))]
+public static class CustomVersionSourceHandler
+{
+ public static VersionSourceIncremented Handle(IncrementWithCustomVersion command,
+ VersionSourceAggregate aggregate)
+ {
+ return new VersionSourceIncremented();
+ }
+}
+
+public static class ParamVersionSourceHandler
+{
+ public static VersionSourceIncremented Handle(IncrementWithParamVersionSource command,
+ [WriteAggregate(VersionSource = nameof(IncrementWithParamVersionSource.MyVersion))]
+ VersionSourceAggregate aggregate)
+ {
+ return new VersionSourceIncremented();
+ }
+}
+
+#endregion
diff --git a/src/Persistence/MartenTests/Dcb/University/AllCoursesFullyBookedState.cs b/src/Persistence/MartenTests/Dcb/University/AllCoursesFullyBookedState.cs
new file mode 100644
index 000000000..75dd51725
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/AllCoursesFullyBookedState.cs
@@ -0,0 +1,58 @@
+namespace MartenTests.Dcb.University;
+
+///
+/// State for the "all courses fully booked" automation.
+/// Tracks all courses and their capacity/subscription counts.
+/// Built from events tagged with FacultyId.
+///
+public class AllCoursesFullyBookedState
+{
+ public Dictionary Courses { get; } = new();
+ public bool Notified { get; private set; }
+
+ public bool AllCoursesFullyBooked =>
+ Courses.Count > 0 && Courses.Values.All(c => c.IsFullyBooked);
+
+ public void Apply(CourseCreated e)
+ {
+ Courses[e.CourseId] = new CourseStats(e.Capacity, 0);
+ ResetNotifiedIfNotAllBooked();
+ }
+
+ public void Apply(CourseCapacityChanged e)
+ {
+ if (Courses.TryGetValue(e.CourseId, out var stats))
+ Courses[e.CourseId] = stats with { Capacity = e.Capacity };
+ ResetNotifiedIfNotAllBooked();
+ }
+
+ public void Apply(StudentSubscribedToCourse e)
+ {
+ if (Courses.TryGetValue(e.CourseId, out var stats))
+ Courses[e.CourseId] = stats with { Students = stats.Students + 1 };
+ ResetNotifiedIfNotAllBooked();
+ }
+
+ public void Apply(StudentUnsubscribedFromCourse e)
+ {
+ if (Courses.TryGetValue(e.CourseId, out var stats))
+ Courses[e.CourseId] = stats with { Students = stats.Students - 1 };
+ ResetNotifiedIfNotAllBooked();
+ }
+
+ public void Apply(AllCoursesFullyBookedNotificationSent e)
+ {
+ Notified = true;
+ }
+
+ private void ResetNotifiedIfNotAllBooked()
+ {
+ if (!AllCoursesFullyBooked)
+ Notified = false;
+ }
+
+ public record CourseStats(int Capacity, int Students)
+ {
+ public bool IsFullyBooked => Students >= Capacity;
+ }
+}
diff --git a/src/Persistence/MartenTests/Dcb/University/BoundaryModelSubscribeStudentToCourse.cs b/src/Persistence/MartenTests/Dcb/University/BoundaryModelSubscribeStudentToCourse.cs
new file mode 100644
index 000000000..2b23f2105
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/BoundaryModelSubscribeStudentToCourse.cs
@@ -0,0 +1,43 @@
+using JasperFx.Events.Tags;
+using Wolverine.Marten;
+
+namespace MartenTests.Dcb.University;
+
+public record BoundaryModelSubscribeStudentToCourse(StudentId StudentId, CourseId CourseId);
+
+#region sample_wolverine_dcb_boundary_model_handler
+public static class BoundaryModelSubscribeStudentHandler
+{
+ public const int MaxCoursesPerStudent = 3;
+
+ public static EventTagQuery Load(BoundaryModelSubscribeStudentToCourse command)
+ => EventTagQuery
+ .For(command.CourseId)
+ .AndEventsOfType()
+ .Or(command.StudentId)
+ .AndEventsOfType();
+
+ public static StudentSubscribedToCourse Handle(
+ BoundaryModelSubscribeStudentToCourse command,
+ [BoundaryModel]
+ SubscriptionState state)
+ {
+ if (state.StudentId == null)
+ throw new InvalidOperationException("Student with given id never enrolled the faculty");
+
+ if (state.CoursesStudentSubscribed >= MaxCoursesPerStudent)
+ throw new InvalidOperationException("Student subscribed to too many courses");
+
+ if (state.CourseId == null)
+ throw new InvalidOperationException("Course with given id does not exist");
+
+ if (state.StudentsSubscribedToCourse >= state.CourseCapacity)
+ throw new InvalidOperationException("Course is fully booked");
+
+ if (state.AlreadySubscribed)
+ throw new InvalidOperationException("Student already subscribed to this course");
+
+ return new StudentSubscribedToCourse(FacultyId.Default, command.StudentId, command.CourseId);
+ }
+}
+#endregion
diff --git a/src/Persistence/MartenTests/Dcb/University/ChangeCourseCapacity.cs b/src/Persistence/MartenTests/Dcb/University/ChangeCourseCapacity.cs
new file mode 100644
index 000000000..d919a9fea
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/ChangeCourseCapacity.cs
@@ -0,0 +1,137 @@
+using Castle.Components.DictionaryAdapter.Xml;
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+using Microsoft.Extensions.Logging;
+using Wolverine;
+using Wolverine.Attributes;
+using Wolverine.Marten;
+
+namespace MartenTests.Dcb.University;
+
+public record ChangeCourseCapacity(CourseId CourseId, int Capacity);
+
+///
+/// Changes a course's capacity. Validates the course exists and capacity differs.
+/// Uses DCB to query by CourseId tag.
+///
+[WolverineIgnore]
+public static class ChangeCourseCapacityHandler
+{
+ public static async Task Handle(ChangeCourseCapacity command, IDocumentSession session)
+ {
+ var query = new EventTagQuery()
+ .Or(command.CourseId)
+ .Or(command.CourseId);
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ var state = boundary.Aggregate;
+ if (state is not { Created: true })
+ throw new InvalidOperationException("Course with given id does not exist");
+
+ if (command.Capacity == state.Capacity)
+ return; // No change needed
+
+ var @event = new CourseCapacityChanged(FacultyId.Default, command.CourseId, command.Capacity);
+ boundary.AppendOne(@event);
+ }
+}
+
+public static class WithDcbChangeCourseCapacityHandler
+{
+ public class State
+ {
+ public bool Created { get; private set; }
+ public int Capacity { get; private set; }
+
+ public void Apply(CourseCreated e)
+ {
+ Created = true;
+ Capacity = e.Capacity;
+ }
+
+ public void Apply(CourseCapacityChanged e)
+ {
+ Capacity = e.Capacity;
+ }
+ }
+
+ public static EventTagQuery Load(ChangeCourseCapacity command)
+ => new EventTagQuery()
+ .Or(command.CourseId)
+ .Or(command.CourseId);
+
+ public static HandlerContinuation Validate(
+ ChangeCourseCapacity command,
+ State state,
+ ILogger logger)
+ {
+ if (state is not { Created: true })
+ {
+ logger.LogDebug("Course with given id {CourseId} does not exist", command.CourseId);
+ return HandlerContinuation.Stop;
+ }
+
+ return HandlerContinuation.Continue;
+ }
+
+ public static CourseCapacityChanged? Handle(ChangeCourseCapacity command, State state)
+ {
+ return command.Capacity != state.Capacity
+ ? new CourseCapacityChanged(FacultyId.Default, command.CourseId, command.Capacity)
+ : null;
+ }
+}
+
+public static class AggregateHandlerChangeCourseCapacityHandler
+{
+ public class Course
+ {
+ public bool Created { get; private set; }
+ public int Capacity { get; private set; }
+
+ public void Apply(CourseCreated e)
+ {
+ Created = true;
+ Capacity = e.Capacity;
+ }
+
+ public void Apply(CourseCapacityChanged e)
+ {
+ Capacity = e.Capacity;
+ }
+ }
+
+ public static EventTagQuery Load(ChangeCourseCapacity command)
+ => new EventTagQuery()
+ .Or(command.CourseId)
+ .Or(command.CourseId);
+
+ public static HandlerContinuation Validate(
+ ChangeCourseCapacity command,
+
+
+ Course state,
+ ILogger logger)
+ {
+ if (state is not { Created: true })
+ {
+ logger.LogDebug("Course with given id {CourseId} does not exist", command.CourseId);
+ return HandlerContinuation.Stop;
+ }
+
+ return HandlerContinuation.Continue;
+ }
+
+ public static CourseCapacityChanged? Handle(ChangeCourseCapacity command,
+
+ // TODO -- see if we could auto-register this with Marten?
+ [WriteAggregate]
+ Course state)
+ {
+ return command.Capacity != state.Capacity
+ ? new CourseCapacityChanged(FacultyId.Default, command.CourseId, command.Capacity)
+ : null;
+ }
+}
+
diff --git a/src/Persistence/MartenTests/Dcb/University/CourseState.cs b/src/Persistence/MartenTests/Dcb/University/CourseState.cs
new file mode 100644
index 000000000..95aeba212
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/CourseState.cs
@@ -0,0 +1,29 @@
+namespace MartenTests.Dcb.University;
+
+///
+/// Aggregate state for a single course, built from events tagged with CourseId.
+/// Used by CreateCourse, RenameCourse, and ChangeCourseCapacity handlers.
+///
+public class CourseState
+{
+ public bool Created { get; private set; }
+ public string? Name { get; private set; }
+ public int Capacity { get; private set; }
+
+ public void Apply(CourseCreated e)
+ {
+ Created = true;
+ Name = e.Name;
+ Capacity = e.Capacity;
+ }
+
+ public void Apply(CourseRenamed e)
+ {
+ Name = e.Name;
+ }
+
+ public void Apply(CourseCapacityChanged e)
+ {
+ Capacity = e.Capacity;
+ }
+}
diff --git a/src/Persistence/MartenTests/Dcb/University/CourseStatsProjection.cs b/src/Persistence/MartenTests/Dcb/University/CourseStatsProjection.cs
new file mode 100644
index 000000000..253170e9f
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/CourseStatsProjection.cs
@@ -0,0 +1,37 @@
+namespace MartenTests.Dcb.University;
+
+///
+/// Read model for course statistics. Ported from the Axon CoursesStatsProjection.
+/// In Marten, this would be an inline or async projection.
+///
+public class CourseStatsReadModel
+{
+ public CourseId CourseId { get; set; } = default!;
+ public string Name { get; set; } = string.Empty;
+ public int Capacity { get; set; }
+ public int SubscribedStudents { get; set; }
+}
+
+public static class CourseStatsProjection
+{
+ public static CourseStatsReadModel Create(CourseCreated e) =>
+ new()
+ {
+ CourseId = e.CourseId,
+ Name = e.Name,
+ Capacity = e.Capacity,
+ SubscribedStudents = 0
+ };
+
+ public static void Apply(CourseRenamed e, CourseStatsReadModel model) =>
+ model.Name = e.Name;
+
+ public static void Apply(CourseCapacityChanged e, CourseStatsReadModel model) =>
+ model.Capacity = e.Capacity;
+
+ public static void Apply(StudentSubscribedToCourse e, CourseStatsReadModel model) =>
+ model.SubscribedStudents++;
+
+ public static void Apply(StudentUnsubscribedFromCourse e, CourseStatsReadModel model) =>
+ model.SubscribedStudents--;
+}
diff --git a/src/Persistence/MartenTests/Dcb/University/CreateCourse.cs b/src/Persistence/MartenTests/Dcb/University/CreateCourse.cs
new file mode 100644
index 000000000..1706322ea
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/CreateCourse.cs
@@ -0,0 +1,26 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.Dcb.University;
+
+public record CreateCourse(CourseId CourseId, string Name, int Capacity);
+
+///
+/// Creates a course if it doesn't already exist.
+/// Uses DCB to query by CourseId tag to check for prior creation.
+///
+public static class CreateCourseHandler
+{
+ public static async Task Handle(CreateCourse command, IDocumentSession session)
+ {
+ var query = new EventTagQuery().Or(command.CourseId);
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ if (boundary.Aggregate is { Created: true })
+ return; // Already created, idempotent
+
+ var @event = new CourseCreated(FacultyId.Default, command.CourseId, command.Name, command.Capacity);
+ boundary.AppendOne(@event);
+ }
+}
diff --git a/src/Persistence/MartenTests/Dcb/University/EnrollStudentInFaculty.cs b/src/Persistence/MartenTests/Dcb/University/EnrollStudentInFaculty.cs
new file mode 100644
index 000000000..2aba9a10b
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/EnrollStudentInFaculty.cs
@@ -0,0 +1,27 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.Dcb.University;
+
+public record EnrollStudentInFaculty(StudentId StudentId, string FirstName, string LastName);
+
+///
+/// Enrolls a student in the faculty (idempotent).
+/// Uses DCB to query by StudentId tag.
+///
+public static class EnrollStudentHandler
+{
+ public static async Task Handle(EnrollStudentInFaculty command, IDocumentSession session)
+ {
+ var query = new EventTagQuery()
+ .Or(command.StudentId);
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ if (boundary.Aggregate is { Exists: true })
+ return; // Already enrolled, idempotent
+
+ var @event = new StudentEnrolledInFaculty(FacultyId.Default, command.StudentId, command.FirstName, command.LastName);
+ boundary.AppendOne(@event);
+ }
+}
diff --git a/src/Persistence/MartenTests/Dcb/University/EnrolledStudentState.cs b/src/Persistence/MartenTests/Dcb/University/EnrolledStudentState.cs
new file mode 100644
index 000000000..788f6e638
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/EnrolledStudentState.cs
@@ -0,0 +1,15 @@
+namespace MartenTests.Dcb.University;
+
+///
+/// Aggregate state for student enrollment, built from events tagged with StudentId.
+/// Used by EnrollStudentInFaculty handler.
+///
+public class EnrolledStudentState
+{
+ public bool Exists { get; private set; }
+
+ public void Apply(StudentEnrolledInFaculty e)
+ {
+ Exists = true;
+ }
+}
diff --git a/src/Persistence/MartenTests/Dcb/University/RenameCourse.cs b/src/Persistence/MartenTests/Dcb/University/RenameCourse.cs
new file mode 100644
index 000000000..d21998668
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/RenameCourse.cs
@@ -0,0 +1,32 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.Dcb.University;
+
+public record RenameCourse(CourseId CourseId, string Name);
+
+///
+/// Renames a course. Validates the course exists and name is different.
+/// Uses DCB to query by CourseId tag.
+///
+public static class RenameCourseHandler
+{
+ public static async Task Handle(RenameCourse command, IDocumentSession session)
+ {
+ var query = new EventTagQuery()
+ .Or(command.CourseId)
+ .Or(command.CourseId);
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ var state = boundary.Aggregate;
+ if (state is not { Created: true })
+ throw new InvalidOperationException("Course with given id does not exist");
+
+ if (command.Name == state.Name)
+ return; // No change needed
+
+ var @event = new CourseRenamed(FacultyId.Default, command.CourseId, command.Name);
+ boundary.AppendOne(@event);
+ }
+}
diff --git a/src/Persistence/MartenTests/Dcb/University/SendAllCoursesFullyBookedNotification.cs b/src/Persistence/MartenTests/Dcb/University/SendAllCoursesFullyBookedNotification.cs
new file mode 100644
index 000000000..e0576bcbe
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/SendAllCoursesFullyBookedNotification.cs
@@ -0,0 +1,36 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.Dcb.University;
+
+public record SendAllCoursesFullyBookedNotification(FacultyId FacultyId);
+
+///
+/// Automation handler that sends a notification when all courses are fully booked.
+/// Uses DCB to query events tagged with FacultyId to build state across all courses.
+///
+/// In the Axon demo this is split into an EventHandler (reactor) and a CommandHandler.
+/// Here we port both patterns as Wolverine handlers.
+///
+public static class AllCoursesFullyBookedHandler
+{
+ public static async Task Handle(SendAllCoursesFullyBookedNotification command, IDocumentSession session)
+ {
+ var query = new EventTagQuery()
+ .Or(command.FacultyId)
+ .Or(command.FacultyId)
+ .Or(command.FacultyId)
+ .Or(command.FacultyId)
+ .Or(command.FacultyId);
+
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ var state = boundary.Aggregate;
+ if (state is { AllCoursesFullyBooked: true, Notified: false })
+ {
+ // In a real app, send notification via INotificationService here
+ boundary.AppendOne(new AllCoursesFullyBookedNotificationSent(command.FacultyId));
+ }
+ }
+}
diff --git a/src/Persistence/MartenTests/Dcb/University/SubscribeStudentToCourse.cs b/src/Persistence/MartenTests/Dcb/University/SubscribeStudentToCourse.cs
new file mode 100644
index 000000000..be55e4354
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/SubscribeStudentToCourse.cs
@@ -0,0 +1,63 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.Dcb.University;
+
+public record SubscribeStudentToCourse(StudentId StudentId, CourseId CourseId);
+
+///
+/// Subscribes a student to a course. This is the most complex DCB handler —
+/// it spans BOTH CourseId and StudentId tag boundaries to enforce:
+/// - Student must be enrolled in faculty
+/// - Student can't subscribe to more than 3 courses
+/// - Course must exist
+/// - Course must have vacant spots
+/// - Student can't already be subscribed to this course
+///
+/// Ported from the Axon demo's EventCriteria.either() pattern which OR's
+/// events matching CourseId with events matching StudentId.
+///
+public static class SubscribeStudentHandler
+{
+ public const int MaxCoursesPerStudent = 3;
+
+ public static async Task Handle(SubscribeStudentToCourse command, IDocumentSession session)
+ {
+ // Query events tagged with CourseId OR StudentId — the DCB spans both
+ var query = new EventTagQuery()
+ .Or(command.CourseId)
+ .Or(command.CourseId)
+ .Or(command.CourseId)
+ .Or(command.CourseId)
+ .Or(command.StudentId)
+ .Or(command.StudentId)
+ .Or(command.StudentId);
+
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ var state = boundary.Aggregate ?? new SubscriptionState();
+ Decide(command, state);
+
+ var @event = new StudentSubscribedToCourse(FacultyId.Default, command.StudentId, command.CourseId);
+ boundary.AppendOne(@event);
+ }
+
+ private static void Decide(SubscribeStudentToCourse command, SubscriptionState state)
+ {
+ if (state.StudentId == null)
+ throw new InvalidOperationException("Student with given id never enrolled the faculty");
+
+ if (state.CoursesStudentSubscribed >= MaxCoursesPerStudent)
+ throw new InvalidOperationException("Student subscribed to too many courses");
+
+ if (state.CourseId == null)
+ throw new InvalidOperationException("Course with given id does not exist");
+
+ if (state.StudentsSubscribedToCourse >= state.CourseCapacity)
+ throw new InvalidOperationException("Course is fully booked");
+
+ if (state.AlreadySubscribed)
+ throw new InvalidOperationException("Student already subscribed to this course");
+ }
+}
diff --git a/src/Persistence/MartenTests/Dcb/University/SubscriptionState.cs b/src/Persistence/MartenTests/Dcb/University/SubscriptionState.cs
new file mode 100644
index 000000000..0363e10e6
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/SubscriptionState.cs
@@ -0,0 +1,55 @@
+#region sample_wolverine_dcb_subscription_state
+namespace MartenTests.Dcb.University;
+/// Built from events tagged with BOTH CourseId and StudentId.
+/// This is the core DCB pattern — the consistency boundary spans multiple streams.
+///
+/// Ported from the Axon SubscribeStudentToCourseCommandHandler.State which uses
+/// EventCriteria.either() to load events matching CourseId OR StudentId.
+///
+public class SubscriptionState
+{
+ public CourseId? CourseId { get; private set; }
+ public int CourseCapacity { get; private set; }
+ public int StudentsSubscribedToCourse { get; private set; }
+
+ public StudentId? StudentId { get; private set; }
+ public int CoursesStudentSubscribed { get; private set; }
+ public bool AlreadySubscribed { get; private set; }
+
+ public void Apply(CourseCreated e)
+ {
+ CourseId = e.CourseId;
+ CourseCapacity = e.Capacity;
+ }
+
+ public void Apply(CourseCapacityChanged e)
+ {
+ CourseCapacity = e.Capacity;
+ }
+
+ public void Apply(StudentEnrolledInFaculty e)
+ {
+ StudentId = e.StudentId;
+ }
+
+ public void Apply(StudentSubscribedToCourse e)
+ {
+ if (e.CourseId == CourseId)
+ StudentsSubscribedToCourse++;
+ if (e.StudentId == StudentId)
+ CoursesStudentSubscribed++;
+ if (e.StudentId == StudentId && e.CourseId == CourseId)
+ AlreadySubscribed = true;
+ }
+
+ public void Apply(StudentUnsubscribedFromCourse e)
+ {
+ if (e.CourseId == CourseId)
+ StudentsSubscribedToCourse--;
+ if (e.StudentId == StudentId)
+ CoursesStudentSubscribed--;
+ if (e.StudentId == StudentId && e.CourseId == CourseId)
+ AlreadySubscribed = false;
+ }
+}
+#endregion
diff --git a/src/Persistence/MartenTests/Dcb/University/UniversityEvents.cs b/src/Persistence/MartenTests/Dcb/University/UniversityEvents.cs
new file mode 100644
index 000000000..b49d395d6
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/UniversityEvents.cs
@@ -0,0 +1,17 @@
+#region sample_wolverine_dcb_university_events
+namespace MartenTests.Dcb.University;
+
+public record CourseCreated(FacultyId FacultyId, CourseId CourseId, string Name, int Capacity);
+
+public record CourseRenamed(FacultyId FacultyId, CourseId CourseId, string Name);
+
+public record CourseCapacityChanged(FacultyId FacultyId, CourseId CourseId, int Capacity);
+
+public record StudentEnrolledInFaculty(FacultyId FacultyId, StudentId StudentId, string FirstName, string LastName);
+
+public record StudentSubscribedToCourse(FacultyId FacultyId, StudentId StudentId, CourseId CourseId);
+
+public record StudentUnsubscribedFromCourse(FacultyId FacultyId, StudentId StudentId, CourseId CourseId);
+
+public record AllCoursesFullyBookedNotificationSent(FacultyId FacultyId);
+#endregion
diff --git a/src/Persistence/MartenTests/Dcb/University/UniversityIds.cs b/src/Persistence/MartenTests/Dcb/University/UniversityIds.cs
new file mode 100644
index 000000000..65288b2be
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/UniversityIds.cs
@@ -0,0 +1,38 @@
+#region sample_wolverine_dcb_university_ids
+namespace MartenTests.Dcb.University;
+
+///
+/// Strong-typed ID for a course. Uses string value with "Course:" prefix.
+///
+public record CourseId(string Value)
+{
+ public static CourseId Random() => new($"Course:{Guid.NewGuid()}");
+ public static CourseId Of(string raw) => new(raw.StartsWith("Course:") ? raw : $"Course:{raw}");
+ public override string ToString() => Value;
+}
+
+///
+/// Strong-typed ID for a student. Uses string value with "Student:" prefix.
+///
+public record StudentId(string Value)
+{
+ public static StudentId Random() => new($"Student:{Guid.NewGuid()}");
+ public static StudentId Of(string raw) => new(raw.StartsWith("Student:") ? raw : $"Student:{raw}");
+ public override string ToString() => Value;
+}
+
+///
+/// Strong-typed ID for the faculty. Single-instance in this demo.
+///
+public record FacultyId(string Value)
+{
+ public static readonly FacultyId Default = new("Faculty:ONLY_FACULTY_ID");
+ public static FacultyId Of(string raw) => new(raw.StartsWith("Faculty:") ? raw : $"Faculty:{raw}");
+ public override string ToString() => Value;
+}
+
+///
+/// Composite ID for a student-course subscription.
+///
+public record SubscriptionId(CourseId CourseId, StudentId StudentId);
+#endregion
diff --git a/src/Persistence/MartenTests/Dcb/University/UnsubscribeStudentFromCourse.cs b/src/Persistence/MartenTests/Dcb/University/UnsubscribeStudentFromCourse.cs
new file mode 100644
index 000000000..545e5f837
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/UnsubscribeStudentFromCourse.cs
@@ -0,0 +1,31 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.Dcb.University;
+
+public record UnsubscribeStudentFromCourse(StudentId StudentId, CourseId CourseId);
+
+///
+/// Unsubscribes a student from a course. Idempotent if not subscribed.
+/// Uses DCB to query by both CourseId AND StudentId tags.
+///
+public static class UnsubscribeStudentHandler
+{
+ public static async Task Handle(UnsubscribeStudentFromCourse command, IDocumentSession session)
+ {
+ var query = new EventTagQuery()
+ .Or(command.CourseId)
+ .Or(command.CourseId)
+ .Or(command.StudentId)
+ .Or(command.StudentId);
+
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ if (boundary.Aggregate is not { Subscribed: true })
+ return; // Not subscribed, nothing to do
+
+ var @event = new StudentUnsubscribedFromCourse(FacultyId.Default, command.StudentId, command.CourseId);
+ boundary.AppendOne(@event);
+ }
+}
diff --git a/src/Persistence/MartenTests/Dcb/University/UnsubscriptionState.cs b/src/Persistence/MartenTests/Dcb/University/UnsubscriptionState.cs
new file mode 100644
index 000000000..90fe95e21
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/University/UnsubscriptionState.cs
@@ -0,0 +1,20 @@
+namespace MartenTests.Dcb.University;
+
+///
+/// State for unsubscribe — tracks whether the student is currently subscribed
+/// to the course. Built from events tagged with both CourseId AND StudentId.
+///
+public class UnsubscriptionState
+{
+ public bool Subscribed { get; private set; }
+
+ public void Apply(StudentSubscribedToCourse e)
+ {
+ Subscribed = true;
+ }
+
+ public void Apply(StudentUnsubscribedFromCourse e)
+ {
+ Subscribed = false;
+ }
+}
diff --git a/src/Persistence/MartenTests/Dcb/boundary_model_workflow_tests.cs b/src/Persistence/MartenTests/Dcb/boundary_model_workflow_tests.cs
new file mode 100644
index 000000000..a8acf92a4
--- /dev/null
+++ b/src/Persistence/MartenTests/Dcb/boundary_model_workflow_tests.cs
@@ -0,0 +1,208 @@
+using IntegrationTests;
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using JasperFx.Resources;
+using Marten;
+using Marten.Events;
+using MartenTests.Dcb.University;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Npgsql;
+using Shouldly;
+using Wolverine;
+using Wolverine.Marten;
+using Wolverine.Tracking;
+
+namespace MartenTests.Dcb;
+
+public class boundary_model_workflow_tests : PostgresqlContext, IAsyncLifetime
+{
+ private IHost theHost;
+ private IDocumentStore theStore;
+
+ public async Task InitializeAsync()
+ {
+ // Drop the schema if it exists to avoid migration conflicts
+ await using (var conn = new NpgsqlConnection(Servers.PostgresConnectionString))
+ {
+ await conn.OpenAsync();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "DROP SCHEMA IF EXISTS dcb_boundary_tests CASCADE;";
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ theHost = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Services.AddMarten(m =>
+ {
+ m.Connection(Servers.PostgresConnectionString);
+ m.DatabaseSchemaName = "dcb_boundary_tests";
+
+ // Register tag types for DCB
+ m.Events.RegisterTagType("student")
+ .ForAggregate();
+ m.Events.RegisterTagType("course")
+ .ForAggregate();
+ m.Events.RegisterTagType("faculty");
+
+ // Register event types
+ m.Events.AddEventType();
+ m.Events.AddEventType();
+ m.Events.AddEventType();
+ m.Events.AddEventType();
+ m.Events.AddEventType();
+
+ m.Events.StreamIdentity = StreamIdentity.AsString;
+
+ m.DisableNpgsqlLogging = true;
+ })
+ .UseLightweightSessions()
+ .IntegrateWithWolverine();
+
+ opts.Services.AddResourceSetupOnStartup();
+ }).StartAsync();
+
+ theStore = theHost.Services.GetRequiredService();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await theHost.StopAsync();
+ theHost.Dispose();
+ }
+
+ private async Task SeedCourseAndStudent(CourseId courseId, StudentId studentId, int capacity = 10)
+ {
+ await using var session = theStore.LightweightSession();
+
+ var courseCreated = session.Events.BuildEvent(
+ new CourseCreated(FacultyId.Default, courseId, "Math 101", capacity));
+ courseCreated.WithTag(courseId);
+ var courseStreamKey = courseId.Value;
+ session.Events.Append(courseStreamKey, courseCreated);
+
+ var enrolled = session.Events.BuildEvent(
+ new StudentEnrolledInFaculty(FacultyId.Default, studentId, "Alice", "Smith"));
+ enrolled.WithTag(studentId);
+ var studentStreamKey = studentId.Value;
+ session.Events.Append(studentStreamKey, enrolled);
+
+ await session.SaveChangesAsync();
+ }
+
+ [Fact]
+ public async Task can_fetch_for_writing_by_tags_across_multiple_tag_types()
+ {
+ var courseId = CourseId.Random();
+ var studentId = StudentId.Random();
+
+ await SeedCourseAndStudent(courseId, studentId);
+
+ await using var session = theStore.LightweightSession();
+
+ var query = new EventTagQuery()
+ .Or(courseId)
+ .Or(studentId);
+
+ var boundary = await session.Events.FetchForWritingByTags(query);
+ boundary.Events.Count.ShouldBe(2);
+ boundary.Aggregate.ShouldNotBeNull();
+ boundary.Aggregate.CourseId.ShouldBe(courseId);
+ boundary.Aggregate.StudentId.ShouldBe(studentId);
+ }
+
+ [Fact]
+ public async Task boundary_model_handler_subscribes_student_to_course()
+ {
+ var courseId = CourseId.Random();
+ var studentId = StudentId.Random();
+
+ await SeedCourseAndStudent(courseId, studentId);
+
+ // Invoke the [BoundaryModel] handler
+ await theHost.InvokeMessageAndWaitAsync(
+ new BoundaryModelSubscribeStudentToCourse(studentId, courseId));
+
+ // Verify the subscription event was appended and discoverable by tag
+ await using var session = theStore.LightweightSession();
+ var events = await session.Events.QueryByTagsAsync(
+ new EventTagQuery().Or(studentId));
+
+ events.ShouldContain(e => e.Data is StudentSubscribedToCourse);
+ }
+
+ [Fact]
+ public async Task boundary_model_handler_throws_when_student_not_enrolled()
+ {
+ var courseId = CourseId.Random();
+ var studentId = StudentId.Random();
+
+ // Only seed the course, NOT the student
+ await using var session = theStore.LightweightSession();
+ var courseCreated = session.Events.BuildEvent(
+ new CourseCreated(FacultyId.Default, courseId, "Math 101", 10));
+ courseCreated.WithTag(courseId);
+ session.Events.Append(courseId.Value, courseCreated);
+ await session.SaveChangesAsync();
+
+ // The handler should throw because student is not enrolled
+ await Should.ThrowAsync(async () =>
+ {
+ await theHost.InvokeMessageAndWaitAsync(
+ new BoundaryModelSubscribeStudentToCourse(studentId, courseId));
+ });
+ }
+
+ [Fact]
+ public async Task boundary_model_handler_throws_when_course_does_not_exist()
+ {
+ var courseId = CourseId.Random();
+ var studentId = StudentId.Random();
+
+ // Only seed the student, NOT the course
+ await using var session = theStore.LightweightSession();
+ var enrolled = session.Events.BuildEvent(
+ new StudentEnrolledInFaculty(FacultyId.Default, studentId, "Alice", "Smith"));
+ enrolled.WithTag(studentId);
+ session.Events.Append(studentId.Value, enrolled);
+ await session.SaveChangesAsync();
+
+ await Should.ThrowAsync(async () =>
+ {
+ await theHost.InvokeMessageAndWaitAsync(
+ new BoundaryModelSubscribeStudentToCourse(studentId, courseId));
+ });
+ }
+
+ [Fact]
+ public async Task boundary_model_handler_throws_when_course_is_fully_booked()
+ {
+ var courseId = CourseId.Random();
+ var studentId = StudentId.Random();
+
+ // Create course with capacity = 1 and fill it
+ await SeedCourseAndStudent(courseId, studentId, capacity: 1);
+
+ // Subscribe a different student first to fill the course
+ var otherStudentId = StudentId.Random();
+ await using var session = theStore.LightweightSession();
+ var otherEnrolled = session.Events.BuildEvent(
+ new StudentEnrolledInFaculty(FacultyId.Default, otherStudentId, "Bob", "Jones"));
+ otherEnrolled.WithTag(otherStudentId);
+ session.Events.Append(otherStudentId.Value, otherEnrolled);
+
+ var subscribed = session.Events.BuildEvent(
+ new StudentSubscribedToCourse(FacultyId.Default, otherStudentId, courseId));
+ subscribed.WithTag(otherStudentId, courseId);
+ session.Events.Append(otherStudentId.Value, subscribed);
+ await session.SaveChangesAsync();
+
+ // Now try to subscribe our student — should fail because course is full
+ await Should.ThrowAsync(async () =>
+ {
+ await theHost.InvokeMessageAndWaitAsync(
+ new BoundaryModelSubscribeStudentToCourse(studentId, courseId));
+ });
+ }
+}
diff --git a/src/Persistence/MartenTests/EventTypeForwarderTests.cs b/src/Persistence/MartenTests/EventTypeForwarderTests.cs
index 52985af87..b9f2721e5 100644
--- a/src/Persistence/MartenTests/EventTypeForwarderTests.cs
+++ b/src/Persistence/MartenTests/EventTypeForwarderTests.cs
@@ -59,4 +59,7 @@ public object GetHeader(string key)
public string AggregateTypeName { get; set; }
public string? UserName { get; set; }
public bool IsSkipped { get; set; }
+ public IReadOnlyList? Tags => null;
+ public void AddTag(TTag tag) where TTag : notnull { }
+ public void AddTag(JasperFx.Events.EventTag tag) { }
}
\ No newline at end of file
diff --git a/src/Persistence/MartenTests/MultiStream/University/AllCoursesFullyBookedState.cs b/src/Persistence/MartenTests/MultiStream/University/AllCoursesFullyBookedState.cs
new file mode 100644
index 000000000..7e16e04fe
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/AllCoursesFullyBookedState.cs
@@ -0,0 +1,58 @@
+namespace MartenTests.MultiStream.University;
+
+///
+/// State for the "all courses fully booked" automation.
+/// Tracks all courses and their capacity/subscription counts.
+/// Built from events tagged with FacultyId.
+///
+public class AllCoursesFullyBookedState
+{
+ public Dictionary Courses { get; } = new();
+ public bool Notified { get; private set; }
+
+ public bool AllCoursesFullyBooked =>
+ Courses.Count > 0 && Courses.Values.All(c => c.IsFullyBooked);
+
+ public void Apply(CourseCreated e)
+ {
+ Courses[e.CourseId] = new CourseStats(e.Capacity, 0);
+ ResetNotifiedIfNotAllBooked();
+ }
+
+ public void Apply(CourseCapacityChanged e)
+ {
+ if (Courses.TryGetValue(e.CourseId, out var stats))
+ Courses[e.CourseId] = stats with { Capacity = e.Capacity };
+ ResetNotifiedIfNotAllBooked();
+ }
+
+ public void Apply(StudentSubscribedToCourse e)
+ {
+ if (Courses.TryGetValue(e.CourseId, out var stats))
+ Courses[e.CourseId] = stats with { Students = stats.Students + 1 };
+ ResetNotifiedIfNotAllBooked();
+ }
+
+ public void Apply(StudentUnsubscribedFromCourse e)
+ {
+ if (Courses.TryGetValue(e.CourseId, out var stats))
+ Courses[e.CourseId] = stats with { Students = stats.Students - 1 };
+ ResetNotifiedIfNotAllBooked();
+ }
+
+ public void Apply(AllCoursesFullyBookedNotificationSent e)
+ {
+ Notified = true;
+ }
+
+ private void ResetNotifiedIfNotAllBooked()
+ {
+ if (!AllCoursesFullyBooked)
+ Notified = false;
+ }
+
+ public record CourseStats(int Capacity, int Students)
+ {
+ public bool IsFullyBooked => Students >= Capacity;
+ }
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/ChangeCourseCapacity.cs b/src/Persistence/MartenTests/MultiStream/University/ChangeCourseCapacity.cs
new file mode 100644
index 000000000..7efc908e5
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/ChangeCourseCapacity.cs
@@ -0,0 +1,32 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.MultiStream.University;
+
+public record ChangeCourseCapacity(CourseId CourseId, int Capacity);
+
+///
+/// Changes a course's capacity. Validates the course exists and capacity differs.
+/// Uses DCB to query by CourseId tag.
+///
+public static class ChangeCourseCapacityHandler
+{
+ public static async Task Handle(ChangeCourseCapacity command, IDocumentSession session)
+ {
+ var query = new EventTagQuery()
+ .Or(command.CourseId)
+ .Or(command.CourseId);
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ var state = boundary.Aggregate;
+ if (state is not { Created: true })
+ throw new InvalidOperationException("Course with given id does not exist");
+
+ if (command.Capacity == state.Capacity)
+ return; // No change needed
+
+ var @event = new CourseCapacityChanged(FacultyId.Default, command.CourseId, command.Capacity);
+ boundary.AppendOne(@event);
+ }
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/CourseState.cs b/src/Persistence/MartenTests/MultiStream/University/CourseState.cs
new file mode 100644
index 000000000..a175859a9
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/CourseState.cs
@@ -0,0 +1,29 @@
+namespace MartenTests.MultiStream.University;
+
+///
+/// Aggregate state for a single course, built from events tagged with CourseId.
+/// Used by CreateCourse, RenameCourse, and ChangeCourseCapacity handlers.
+///
+public class CourseState
+{
+ public bool Created { get; private set; }
+ public string? Name { get; private set; }
+ public int Capacity { get; private set; }
+
+ public void Apply(CourseCreated e)
+ {
+ Created = true;
+ Name = e.Name;
+ Capacity = e.Capacity;
+ }
+
+ public void Apply(CourseRenamed e)
+ {
+ Name = e.Name;
+ }
+
+ public void Apply(CourseCapacityChanged e)
+ {
+ Capacity = e.Capacity;
+ }
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/CourseStatsProjection.cs b/src/Persistence/MartenTests/MultiStream/University/CourseStatsProjection.cs
new file mode 100644
index 000000000..5f72eaa44
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/CourseStatsProjection.cs
@@ -0,0 +1,37 @@
+namespace MartenTests.MultiStream.University;
+
+///
+/// Read model for course statistics. Ported from the Axon CoursesStatsProjection.
+/// In Marten, this would be an inline or async projection.
+///
+public class CourseStatsReadModel
+{
+ public CourseId CourseId { get; set; } = default!;
+ public string Name { get; set; } = string.Empty;
+ public int Capacity { get; set; }
+ public int SubscribedStudents { get; set; }
+}
+
+public static class CourseStatsProjection
+{
+ public static CourseStatsReadModel Create(CourseCreated e) =>
+ new()
+ {
+ CourseId = e.CourseId,
+ Name = e.Name,
+ Capacity = e.Capacity,
+ SubscribedStudents = 0
+ };
+
+ public static void Apply(CourseRenamed e, CourseStatsReadModel model) =>
+ model.Name = e.Name;
+
+ public static void Apply(CourseCapacityChanged e, CourseStatsReadModel model) =>
+ model.Capacity = e.Capacity;
+
+ public static void Apply(StudentSubscribedToCourse e, CourseStatsReadModel model) =>
+ model.SubscribedStudents++;
+
+ public static void Apply(StudentUnsubscribedFromCourse e, CourseStatsReadModel model) =>
+ model.SubscribedStudents--;
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/CreateCourse.cs b/src/Persistence/MartenTests/MultiStream/University/CreateCourse.cs
new file mode 100644
index 000000000..31c9a309d
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/CreateCourse.cs
@@ -0,0 +1,26 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.MultiStream.University;
+
+public record CreateCourse(CourseId CourseId, string Name, int Capacity);
+
+///
+/// Creates a course if it doesn't already exist.
+/// Uses DCB to query by CourseId tag to check for prior creation.
+///
+public static class CreateCourseHandler
+{
+ public static async Task Handle(CreateCourse command, IDocumentSession session)
+ {
+ var query = new EventTagQuery().Or(command.CourseId);
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ if (boundary.Aggregate is { Created: true })
+ return; // Already created, idempotent
+
+ var @event = new CourseCreated(FacultyId.Default, command.CourseId, command.Name, command.Capacity);
+ boundary.AppendOne(@event);
+ }
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/EnrollStudentInFaculty.cs b/src/Persistence/MartenTests/MultiStream/University/EnrollStudentInFaculty.cs
new file mode 100644
index 000000000..46c745579
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/EnrollStudentInFaculty.cs
@@ -0,0 +1,27 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.MultiStream.University;
+
+public record EnrollStudentInFaculty(StudentId StudentId, string FirstName, string LastName);
+
+///
+/// Enrolls a student in the faculty (idempotent).
+/// Uses DCB to query by StudentId tag.
+///
+public static class EnrollStudentHandler
+{
+ public static async Task Handle(EnrollStudentInFaculty command, IDocumentSession session)
+ {
+ var query = new EventTagQuery()
+ .Or(command.StudentId);
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ if (boundary.Aggregate is { Exists: true })
+ return; // Already enrolled, idempotent
+
+ var @event = new StudentEnrolledInFaculty(FacultyId.Default, command.StudentId, command.FirstName, command.LastName);
+ boundary.AppendOne(@event);
+ }
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/EnrolledStudentState.cs b/src/Persistence/MartenTests/MultiStream/University/EnrolledStudentState.cs
new file mode 100644
index 000000000..c8bd91e02
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/EnrolledStudentState.cs
@@ -0,0 +1,15 @@
+namespace MartenTests.MultiStream.University;
+
+///
+/// Aggregate state for student enrollment, built from events tagged with StudentId.
+/// Used by EnrollStudentInFaculty handler.
+///
+public class EnrolledStudentState
+{
+ public bool Exists { get; private set; }
+
+ public void Apply(StudentEnrolledInFaculty e)
+ {
+ Exists = true;
+ }
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/RenameCourse.cs b/src/Persistence/MartenTests/MultiStream/University/RenameCourse.cs
new file mode 100644
index 000000000..48e1655dc
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/RenameCourse.cs
@@ -0,0 +1,32 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.MultiStream.University;
+
+public record RenameCourse(CourseId CourseId, string Name);
+
+///
+/// Renames a course. Validates the course exists and name is different.
+/// Uses DCB to query by CourseId tag.
+///
+public static class RenameCourseHandler
+{
+ public static async Task Handle(RenameCourse command, IDocumentSession session)
+ {
+ var query = new EventTagQuery()
+ .Or(command.CourseId)
+ .Or(command.CourseId);
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ var state = boundary.Aggregate;
+ if (state is not { Created: true })
+ throw new InvalidOperationException("Course with given id does not exist");
+
+ if (command.Name == state.Name)
+ return; // No change needed
+
+ var @event = new CourseRenamed(FacultyId.Default, command.CourseId, command.Name);
+ boundary.AppendOne(@event);
+ }
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/SendAllCoursesFullyBookedNotification.cs b/src/Persistence/MartenTests/MultiStream/University/SendAllCoursesFullyBookedNotification.cs
new file mode 100644
index 000000000..46706ae5e
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/SendAllCoursesFullyBookedNotification.cs
@@ -0,0 +1,36 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.MultiStream.University;
+
+public record SendAllCoursesFullyBookedNotification(FacultyId FacultyId);
+
+///
+/// Automation handler that sends a notification when all courses are fully booked.
+/// Uses DCB to query events tagged with FacultyId to build state across all courses.
+///
+/// In the Axon demo this is split into an EventHandler (reactor) and a CommandHandler.
+/// Here we port both patterns as Wolverine handlers.
+///
+public static class AllCoursesFullyBookedHandler
+{
+ public static async Task Handle(SendAllCoursesFullyBookedNotification command, IDocumentSession session)
+ {
+ var query = new EventTagQuery()
+ .Or(command.FacultyId)
+ .Or(command.FacultyId)
+ .Or(command.FacultyId)
+ .Or(command.FacultyId)
+ .Or(command.FacultyId);
+
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ var state = boundary.Aggregate;
+ if (state is { AllCoursesFullyBooked: true, Notified: false })
+ {
+ // In a real app, send notification via INotificationService here
+ boundary.AppendOne(new AllCoursesFullyBookedNotificationSent(command.FacultyId));
+ }
+ }
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/SubscribeStudentToCourse.cs b/src/Persistence/MartenTests/MultiStream/University/SubscribeStudentToCourse.cs
new file mode 100644
index 000000000..1f1e821fd
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/SubscribeStudentToCourse.cs
@@ -0,0 +1,63 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.MultiStream.University;
+
+public record SubscribeStudentToCourse(StudentId StudentId, CourseId CourseId);
+
+///
+/// Subscribes a student to a course. This is the most complex DCB handler —
+/// it spans BOTH CourseId and StudentId tag boundaries to enforce:
+/// - Student must be enrolled in faculty
+/// - Student can't subscribe to more than 3 courses
+/// - Course must exist
+/// - Course must have vacant spots
+/// - Student can't already be subscribed to this course
+///
+/// Ported from the Axon demo's EventCriteria.either() pattern which OR's
+/// events matching CourseId with events matching StudentId.
+///
+public static class SubscribeStudentHandler
+{
+ public const int MaxCoursesPerStudent = 3;
+
+ public static async Task Handle(SubscribeStudentToCourse command, IDocumentSession session)
+ {
+ // Query events tagged with CourseId OR StudentId — the DCB spans both
+ var query = new EventTagQuery()
+ .Or(command.CourseId)
+ .Or(command.CourseId)
+ .Or(command.CourseId)
+ .Or(command.CourseId)
+ .Or(command.StudentId)
+ .Or(command.StudentId)
+ .Or(command.StudentId);
+
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ var state = boundary.Aggregate ?? new SubscriptionState();
+ Decide(command, state);
+
+ var @event = new StudentSubscribedToCourse(FacultyId.Default, command.StudentId, command.CourseId);
+ boundary.AppendOne(@event);
+ }
+
+ private static void Decide(SubscribeStudentToCourse command, SubscriptionState state)
+ {
+ if (state.StudentId == null)
+ throw new InvalidOperationException("Student with given id never enrolled the faculty");
+
+ if (state.CoursesStudentSubscribed >= MaxCoursesPerStudent)
+ throw new InvalidOperationException("Student subscribed to too many courses");
+
+ if (state.CourseId == null)
+ throw new InvalidOperationException("Course with given id does not exist");
+
+ if (state.StudentsSubscribedToCourse >= state.CourseCapacity)
+ throw new InvalidOperationException("Course is fully booked");
+
+ if (state.AlreadySubscribed)
+ throw new InvalidOperationException("Student already subscribed to this course");
+ }
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/SubscriptionState.cs b/src/Persistence/MartenTests/MultiStream/University/SubscriptionState.cs
new file mode 100644
index 000000000..7a74d796a
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/SubscriptionState.cs
@@ -0,0 +1,56 @@
+namespace MartenTests.MultiStream.University;
+
+///
+/// Cross-stream aggregate state for a student subscribing to a course.
+/// Built from events tagged with BOTH CourseId and StudentId.
+/// This is the core DCB pattern — the consistency boundary spans multiple streams.
+///
+/// Ported from the Axon SubscribeStudentToCourseCommandHandler.State which uses
+/// EventCriteria.either() to load events matching CourseId OR StudentId.
+///
+public class SubscriptionState
+{
+ public CourseId? CourseId { get; private set; }
+ public int CourseCapacity { get; private set; }
+ public int StudentsSubscribedToCourse { get; private set; }
+
+ public StudentId? StudentId { get; private set; }
+ public int CoursesStudentSubscribed { get; private set; }
+ public bool AlreadySubscribed { get; private set; }
+
+ public void Apply(CourseCreated e)
+ {
+ CourseId = e.CourseId;
+ CourseCapacity = e.Capacity;
+ }
+
+ public void Apply(CourseCapacityChanged e)
+ {
+ CourseCapacity = e.Capacity;
+ }
+
+ public void Apply(StudentEnrolledInFaculty e)
+ {
+ StudentId = e.StudentId;
+ }
+
+ public void Apply(StudentSubscribedToCourse e)
+ {
+ if (e.CourseId == CourseId)
+ StudentsSubscribedToCourse++;
+ if (e.StudentId == StudentId)
+ CoursesStudentSubscribed++;
+ if (e.StudentId == StudentId && e.CourseId == CourseId)
+ AlreadySubscribed = true;
+ }
+
+ public void Apply(StudentUnsubscribedFromCourse e)
+ {
+ if (e.CourseId == CourseId)
+ StudentsSubscribedToCourse--;
+ if (e.StudentId == StudentId)
+ CoursesStudentSubscribed--;
+ if (e.StudentId == StudentId && e.CourseId == CourseId)
+ AlreadySubscribed = false;
+ }
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/UniversityEvents.cs b/src/Persistence/MartenTests/MultiStream/University/UniversityEvents.cs
new file mode 100644
index 000000000..8e2978eac
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/UniversityEvents.cs
@@ -0,0 +1,18 @@
+namespace MartenTests.MultiStream.University;
+
+// All events carry their tag IDs as properties, matching the Axon @EventTag pattern.
+// In Marten, tags are attached to events via WithTag() at append time.
+
+public record CourseCreated(FacultyId FacultyId, CourseId CourseId, string Name, int Capacity);
+
+public record CourseRenamed(FacultyId FacultyId, CourseId CourseId, string Name);
+
+public record CourseCapacityChanged(FacultyId FacultyId, CourseId CourseId, int Capacity);
+
+public record StudentEnrolledInFaculty(FacultyId FacultyId, StudentId StudentId, string FirstName, string LastName);
+
+public record StudentSubscribedToCourse(FacultyId FacultyId, StudentId StudentId, CourseId CourseId);
+
+public record StudentUnsubscribedFromCourse(FacultyId FacultyId, StudentId StudentId, CourseId CourseId);
+
+public record AllCoursesFullyBookedNotificationSent(FacultyId FacultyId);
diff --git a/src/Persistence/MartenTests/MultiStream/University/UniversityIds.cs b/src/Persistence/MartenTests/MultiStream/University/UniversityIds.cs
new file mode 100644
index 000000000..74725c60e
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/UniversityIds.cs
@@ -0,0 +1,36 @@
+namespace MartenTests.MultiStream.University;
+
+///
+/// Strong-typed ID for a course. Uses string value with "Course:" prefix.
+///
+public record CourseId(string Value)
+{
+ public static CourseId Random() => new($"Course:{Guid.NewGuid()}");
+ public static CourseId Of(string raw) => new(raw.StartsWith("Course:") ? raw : $"Course:{raw}");
+ public override string ToString() => Value;
+}
+
+///
+/// Strong-typed ID for a student. Uses string value with "Student:" prefix.
+///
+public record StudentId(string Value)
+{
+ public static StudentId Random() => new($"Student:{Guid.NewGuid()}");
+ public static StudentId Of(string raw) => new(raw.StartsWith("Student:") ? raw : $"Student:{raw}");
+ public override string ToString() => Value;
+}
+
+///
+/// Strong-typed ID for the faculty. Single-instance in this demo.
+///
+public record FacultyId(string Value)
+{
+ public static readonly FacultyId Default = new("Faculty:ONLY_FACULTY_ID");
+ public static FacultyId Of(string raw) => new(raw.StartsWith("Faculty:") ? raw : $"Faculty:{raw}");
+ public override string ToString() => Value;
+}
+
+///
+/// Composite ID for a student-course subscription.
+///
+public record SubscriptionId(CourseId CourseId, StudentId StudentId);
diff --git a/src/Persistence/MartenTests/MultiStream/University/UnsubscribeStudentFromCourse.cs b/src/Persistence/MartenTests/MultiStream/University/UnsubscribeStudentFromCourse.cs
new file mode 100644
index 000000000..89bf533db
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/UnsubscribeStudentFromCourse.cs
@@ -0,0 +1,31 @@
+using JasperFx.Events;
+using JasperFx.Events.Tags;
+using Marten;
+
+namespace MartenTests.MultiStream.University;
+
+public record UnsubscribeStudentFromCourse(StudentId StudentId, CourseId CourseId);
+
+///
+/// Unsubscribes a student from a course. Idempotent if not subscribed.
+/// Uses DCB to query by both CourseId AND StudentId tags.
+///
+public static class UnsubscribeStudentHandler
+{
+ public static async Task Handle(UnsubscribeStudentFromCourse command, IDocumentSession session)
+ {
+ var query = new EventTagQuery()
+ .Or(command.CourseId)
+ .Or(command.CourseId)
+ .Or(command.StudentId)
+ .Or(command.StudentId);
+
+ var boundary = await session.Events.FetchForWritingByTags(query);
+
+ if (boundary.Aggregate is not { Subscribed: true })
+ return; // Not subscribed, nothing to do
+
+ var @event = new StudentUnsubscribedFromCourse(FacultyId.Default, command.StudentId, command.CourseId);
+ boundary.AppendOne(@event);
+ }
+}
diff --git a/src/Persistence/MartenTests/MultiStream/University/UnsubscriptionState.cs b/src/Persistence/MartenTests/MultiStream/University/UnsubscriptionState.cs
new file mode 100644
index 000000000..86cbea7e2
--- /dev/null
+++ b/src/Persistence/MartenTests/MultiStream/University/UnsubscriptionState.cs
@@ -0,0 +1,20 @@
+namespace MartenTests.MultiStream.University;
+
+///