From 2afcb69bbd9a1f1d68796de95f3fab56fc9bc671 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 21 Aug 2026 12:10:07 +0200 Subject: [PATCH 1/4] Document subscription handler registration changes Covers the fix for handlers registered with a factory collapsing into one, the disposal of factory-created handlers by the subscription, and the new rejection of duplicate handler types on a single subscription. Co-Authored-By: Claude Opus 5 --- src/content/docs/dotnet-next/whats-new.mdx | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/content/docs/dotnet-next/whats-new.mdx b/src/content/docs/dotnet-next/whats-new.mdx index 48652b1..41cd5ce 100644 --- a/src/content/docs/dotnet-next/whats-new.mdx +++ b/src/content/docs/dotnet-next/whats-new.mdx @@ -6,8 +6,38 @@ sidebar: This page tracks changes for the next release of Eventuous. +## Breaking changes + +### One handler type per subscription + +Adding the same handler type twice to a single subscription now throws `ArgumentException` when the subscription is registered: + +```csharp +services.AddSubscription( + "payments", + builder => builder + .AddEventHandler() + .AddEventHandler() // throws +); +``` + +Both registrations claimed the same container slot, keyed by handler type and subscription id, so the subscription dispatched every event to the same instance twice — silently. The same applies to `AddCompositionEventHandler(getWrappingHandler)`, which resolves its inner handler from that slot. + +When a subscription genuinely needs two handlers of the same type, register them with the factory or the instance overload, which keep one handler per registration. + ## New features +### Event handlers created by a factory are disposed + +A handler created by `AddEventHandler(sp => ...)`, or an inner handler created by `AddCompositionEventHandler(getInnerHandler, ...)`, is now owned by the subscription. If it implements `IDisposable` or `IAsyncDisposable`, it gets disposed when the subscription is disposed, after the consume pipe filters have drained the messages in flight, with `IAsyncDisposable` preferred when both are implemented: + +```csharp +builder.AddEventHandler(sp => new ProjectionHandler(sp.GetRequiredService())); +``` + +Handlers registered by type are created and disposed by the container as before, and handlers passed as an instance stay the caller's to dispose. A wrapping handler produced by `AddCompositionEventHandler` decorates the inner one and isn't disposed. + + ### Multi-stream append The `IEventWriter` interface now supports appending events to multiple streams in a single operation. Each append specifies a target stream, expected version, and events. Atomicity depends on the store implementation — relational stores use a single transaction, KurrentDB 25.1+ uses native multi-stream append, and the default fallback writes sequentially. @@ -109,3 +139,26 @@ await client.SubscribeTyped("Order-123", fromPosition: 0) See the [SignalR documentation](../infra/signalr) for full details including auto-reconnect behavior and custom hubs. +## Bug fixes + +### Handlers registered with a factory no longer collapse into one + +`AddEventHandler(Func)` registered the handler in the container keyed by the subscription id, then resolved it back by `THandler`. When several handlers were added to one subscription through this overload and `THandler` inferred to the same type for each call — which happens naturally when the factories are held in a collection typed `Func` — every registration after the first silently did nothing, and the subscription ran several copies of the first handler and none of the others: + +```csharp +IReadOnlyList> projectors = [ + _ => new FactProjector(), + _ => new VectorProjector(), + _ => new ClusterProjector() +]; + +// Before: three copies of FactProjector. Now: one of each. +foreach (var projector in projectors) builder.AddEventHandler(projector); +``` + +Nothing failed and nothing was logged, so with idempotent projectors the duplicate dispatch was invisible and the missing handlers only showed up as missing data. + +Factory-created handlers are now kept by the subscription rather than the container, so each registration keeps its own handler. The same fix applies to the `AddCompositionEventHandler` overloads that build the inner handler from a factory. + +As a consequence, a handler registered with a factory is no longer resolvable from the container with `GetRequiredKeyedService(subscriptionId)`. Handlers registered by type still are. + From 3bc702a0ce4f85e96c825eb95eb8512c8c05c97d Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 21 Aug 2026 12:38:30 +0200 Subject: [PATCH 2/4] Make handler disposal opt-in in the release notes Follows the review on Eventuous/eventuous#577: a handler factory may return a handler owned elsewhere, so the subscription only disposes handlers the caller explicitly hands it. Co-Authored-By: Claude Opus 5 --- src/content/docs/dotnet-next/whats-new.mdx | 23 ++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/content/docs/dotnet-next/whats-new.mdx b/src/content/docs/dotnet-next/whats-new.mdx index 41cd5ce..ea0d904 100644 --- a/src/content/docs/dotnet-next/whats-new.mdx +++ b/src/content/docs/dotnet-next/whats-new.mdx @@ -27,15 +27,30 @@ When a subscription genuinely needs two handlers of the same type, register them ## New features -### Event handlers created by a factory are disposed +### Subscriptions can own and dispose handlers built by a factory -A handler created by `AddEventHandler(sp => ...)`, or an inner handler created by `AddCompositionEventHandler(getInnerHandler, ...)`, is now owned by the subscription. If it implements `IDisposable` or `IAsyncDisposable`, it gets disposed when the subscription is disposed, after the consume pipe filters have drained the messages in flight, with `IAsyncDisposable` preferred when both are implemented: +A handler registered with a factory isn't in the container, so nothing disposes it. New overloads let the subscription take that on: ```csharp -builder.AddEventHandler(sp => new ProjectionHandler(sp.GetRequiredService())); +builder.AddEventHandler(sp => new ProjectionHandler(sp.GetRequiredService()), ownsHandler: true); + +builder.AddCompositionEventHandler( + sp => new ProjectionHandler(sp.GetRequiredService()), + handler => new PollyEventHandler(handler, retryPolicy), + ownsInnerHandler: true +); +``` + +An owned handler implementing `IDisposable` or `IAsyncDisposable` is disposed when the subscription is disposed, after the consume pipe filters have drained the messages in flight, with `IAsyncDisposable` preferred when both are implemented. + +Ownership is opt-in because a factory is free to return a handler it didn't create: + +```csharp +// Owned by the container and possibly shared, so don't claim it +builder.AddEventHandler(sp => sp.GetRequiredService()); ``` -Handlers registered by type are created and disposed by the container as before, and handlers passed as an instance stay the caller's to dispose. A wrapping handler produced by `AddCompositionEventHandler` decorates the inner one and isn't disposed. +Handlers registered by type are created and disposed by the container as before, and handlers passed as an instance stay the caller's to dispose. A wrapping handler produced by `AddCompositionEventHandler` decorates the inner one and is never disposed. ### Multi-stream append From 879e91a925cdfadbce373d99b95cc4c706abb805 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 21 Aug 2026 12:39:39 +0200 Subject: [PATCH 3/4] Show the ownership argument in the container-owned handler sample Co-Authored-By: Claude Opus 5 --- src/content/docs/dotnet-next/whats-new.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/content/docs/dotnet-next/whats-new.mdx b/src/content/docs/dotnet-next/whats-new.mdx index ea0d904..0ab0064 100644 --- a/src/content/docs/dotnet-next/whats-new.mdx +++ b/src/content/docs/dotnet-next/whats-new.mdx @@ -46,8 +46,9 @@ An owned handler implementing `IDisposable` or `IAsyncDisposable` is disposed wh Ownership is opt-in because a factory is free to return a handler it didn't create: ```csharp -// Owned by the container and possibly shared, so don't claim it -builder.AddEventHandler(sp => sp.GetRequiredService()); +// Owned by the container and possibly shared, so don't claim it. False is the default, +// so the original overload without the argument behaves the same way. +builder.AddEventHandler(sp => sp.GetRequiredService(), ownsHandler: false); ``` Handlers registered by type are created and disposed by the container as before, and handlers passed as an instance stay the caller's to dispose. A wrapping handler produced by `AddCompositionEventHandler` decorates the inner one and is never disposed. From 5e39f933330509d929b7f9331fb5e652072e6ebb Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 21 Aug 2026 12:44:05 +0200 Subject: [PATCH 4/4] Own factory-built handlers by default in the release notes Co-Authored-By: Claude Opus 5 --- src/content/docs/dotnet-next/whats-new.mdx | 27 +++++++++++----------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/content/docs/dotnet-next/whats-new.mdx b/src/content/docs/dotnet-next/whats-new.mdx index 0ab0064..735f0da 100644 --- a/src/content/docs/dotnet-next/whats-new.mdx +++ b/src/content/docs/dotnet-next/whats-new.mdx @@ -27,30 +27,31 @@ When a subscription genuinely needs two handlers of the same type, register them ## New features -### Subscriptions can own and dispose handlers built by a factory +### Subscriptions dispose the handlers their factories build -A handler registered with a factory isn't in the container, so nothing disposes it. New overloads let the subscription take that on: +A handler registered with a factory is no longer in the container, so the subscription owns it and disposes it: ```csharp -builder.AddEventHandler(sp => new ProjectionHandler(sp.GetRequiredService()), ownsHandler: true); - -builder.AddCompositionEventHandler( - sp => new ProjectionHandler(sp.GetRequiredService()), - handler => new PollyEventHandler(handler, retryPolicy), - ownsInnerHandler: true -); +builder.AddEventHandler(sp => new ProjectionHandler(sp.GetRequiredService())); ``` -An owned handler implementing `IDisposable` or `IAsyncDisposable` is disposed when the subscription is disposed, after the consume pipe filters have drained the messages in flight, with `IAsyncDisposable` preferred when both are implemented. +If the handler implements `IDisposable` or `IAsyncDisposable`, it is disposed when the subscription is disposed, after the consume pipe filters have drained the messages in flight, with `IAsyncDisposable` preferred when both are implemented. The same applies to an inner handler built by `AddCompositionEventHandler(getInnerHandler, ...)`. -Ownership is opt-in because a factory is free to return a handler it didn't create: +A factory is free to return a handler it didn't create, and disposing that would break the other components using it. New overloads decline ownership for that case: ```csharp -// Owned by the container and possibly shared, so don't claim it. False is the default, -// so the original overload without the argument behaves the same way. +// Owned by the container and possibly shared elsewhere builder.AddEventHandler(sp => sp.GetRequiredService(), ownsHandler: false); + +builder.AddCompositionEventHandler( + sp => sp.GetRequiredService(), + handler => new PollyEventHandler(handler, retryPolicy), + ownsInnerHandler: false +); ``` +To have the container create and own the handler instead, use the `AddEventHandler()` overload, which registers it keyed by subscription id as before. + Handlers registered by type are created and disposed by the container as before, and handlers passed as an instance stay the caller's to dispose. A wrapping handler produced by `AddCompositionEventHandler` decorates the inner one and is never disposed.