Skip to content

Commit 3dd70bc

Browse files
committed
refactor(commands): drop parameterless context factory in favor of explicit and seeded dispatch
Remove the redundant ICommandContextFactory<TContext> (arity-1) and the context-less DispatchAsync<TCommand>(command) overload. In multi-session servers a parameterless Create() cannot know which session a command belongs to, making it a footgun. Callers now pass the context explicitly via DispatchAsync(command, context) or build it from a seed through ISeededCommandDispatcher<TContext, TSeed>. - Delete ICommandContextFactory.cs (arity-1) and its RegisterCommandContextFactory extension - Simplify CommandDispatcher to a single ctor and register it directly as a singleton - Drop the context-less interface overload and implementation - Update samples, tests, and Core/Services.Core READMEs accordingly
1 parent bcac00f commit 3dd70bc

9 files changed

Lines changed: 15 additions & 160 deletions

File tree

samples/SquidStd.Samples.Commands/Program.cs

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using SquidStd.Services.Core.Extensions;
66

77
var container = new Container();
8-
container.RegisterCommandContextFactory<Session, CurrentSessionFactory>();
98
container.RegisterCommandDispatcher<Session>();
109
container.RegisterCommandHandler<PingCommand, Session, PingHandler>();
1110
container.RegisterCommandHandler<EchoCommand, Session, EchoHandler>();
@@ -20,19 +19,23 @@
2019
registration.Subscribe(dispatcher, container);
2120
}
2221

23-
await Dispatch(dispatcher, new PingCommand());
24-
await Dispatch(dispatcher, new EchoCommand("hello world"));
22+
// The context (here a Session) is passed explicitly at dispatch time — in a server this is the
23+
// session the message arrived on. See RegisterSeededCommandDispatcher for building it from a seed.
24+
var session = new Session();
25+
26+
await Dispatch(dispatcher, new PingCommand(), session);
27+
await Dispatch(dispatcher, new EchoCommand("hello world"), session);
2528

2629
// Unknown command path: nothing registered for UnknownCommand.
27-
var unknown = await dispatcher.DispatchAsync(new UnknownCommand());
30+
var unknown = await dispatcher.DispatchAsync(new UnknownCommand(), session);
2831
Console.WriteLine($"UnknownCommand -> matched={unknown.Matched} handlers={unknown.HandlerCount}");
2932

3033
return;
3134

32-
static async Task Dispatch<TCommand>(ICommandDispatcher<Session> dispatcher, TCommand command)
35+
static async Task Dispatch<TCommand>(ICommandDispatcher<Session> dispatcher, TCommand command, Session context)
3336
where TCommand : ICommand
3437
{
35-
var result = await dispatcher.DispatchAsync(command);
38+
var result = await dispatcher.DispatchAsync(command, context);
3639
Console.WriteLine(
3740
$"{typeof(TCommand).Name} -> matched={result.Matched} handlers={result.HandlerCount} errors={result.Errors.Count}"
3841
);
@@ -43,14 +46,6 @@ internal sealed class Session
4346
public string Id { get; } = Guid.NewGuid().ToString("N")[..8];
4447
}
4548

46-
internal sealed class CurrentSessionFactory : ICommandContextFactory<Session>
47-
{
48-
public Session Create()
49-
{
50-
return new Session();
51-
}
52-
}
53-
5449
internal sealed record PingCommand : ICommand;
5550

5651
internal sealed record EchoCommand(string Text) : ICommand;

src/SquidStd.Core/Interfaces/Commands/ICommandContextFactory.cs

Lines changed: 0 additions & 13 deletions
This file was deleted.

src/SquidStd.Core/Interfaces/Commands/ICommandDispatcher.cs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,4 @@ public interface ICommandDispatcher<TContext>
2929
/// <returns>The dispatch result.</returns>
3030
Task<CommandDispatchResult> DispatchAsync<TCommand>(TCommand command, TContext context, CancellationToken cancellationToken = default)
3131
where TCommand : ICommand;
32-
33-
/// <summary>
34-
/// Dispatches a command, building the context from the registered
35-
/// <see cref="ICommandContextFactory{TContext}" />.
36-
/// </summary>
37-
/// <typeparam name="TCommand">The command type.</typeparam>
38-
/// <param name="command">The command payload.</param>
39-
/// <param name="cancellationToken">The cancellation token.</param>
40-
/// <returns>The dispatch result.</returns>
41-
Task<CommandDispatchResult> DispatchAsync<TCommand>(TCommand command, CancellationToken cancellationToken = default)
42-
where TCommand : ICommand;
4332
}

src/SquidStd.Core/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ dotnet add package SquidStd.Core
2626

2727
- Configuration contracts: `IConfigEntry` (a YAML section) and `IConfigManagerService`.
2828
- In-process messaging: `IEventBus` with `ISyncEventListener<T>` / `IAsyncEventListener<T>` over `IEvent`.
29-
- Command dispatch: `ICommandDispatcher<TContext>` with `ICommandHandler<TCommand,TContext>`, fan-out, fault isolation, and a `CommandDispatchResult`; `ICommandContextFactory<TContext>` builds the current context.
29+
- Command dispatch: `ICommandDispatcher<TContext>` with `ICommandHandler<TCommand,TContext>`, fan-out, fault isolation, and a `CommandDispatchResult`; `ICommandContextFactory<TContext,TSeed>` builds the context from a seed for `ISeededCommandDispatcher<TContext,TSeed>`.
3030
- Background work & timing: `IJobSystem`, `ITimerService`, `IMainThreadDispatcher`.
3131
- Metrics & secrets: `IMetricProvider` and secret-protection contracts.
3232
- Serialization: `IDataSerializer` / `IDataDeserializer` (default `JsonDataSerializer`), plus `YamlUtils` / `JsonUtils`.

src/SquidStd.Services.Core/Extensions/RegisterCommandDispatcherExtensions.cs

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,31 +20,12 @@ public static class RegisterCommandDispatcherExtensions
2020
/// <returns>The same container for chaining.</returns>
2121
public IContainer RegisterCommandDispatcher<TContext>()
2222
{
23-
container.RegisterDelegate<ICommandDispatcher<TContext>>(
24-
resolver => new CommandDispatcher<TContext>(
25-
resolver.Resolve<ICommandContextFactory<TContext>>(IfUnresolved.ReturnDefault)
26-
),
27-
Reuse.Singleton
28-
);
23+
container.Register<ICommandDispatcher<TContext>, CommandDispatcher<TContext>>(Reuse.Singleton);
2924
container.RegisterStdService<CommandDispatcherActivator<TContext>, CommandDispatcherActivator<TContext>>(-900);
3025

3126
return container;
3227
}
3328

34-
/// <summary>
35-
/// Registers the context factory used by the context-less dispatch overload.
36-
/// </summary>
37-
/// <typeparam name="TContext">The dispatcher context type.</typeparam>
38-
/// <typeparam name="TFactory">The factory implementation type.</typeparam>
39-
/// <returns>The same container for chaining.</returns>
40-
public IContainer RegisterCommandContextFactory<TContext, TFactory>()
41-
where TFactory : class, ICommandContextFactory<TContext>
42-
{
43-
container.Register<ICommandContextFactory<TContext>, TFactory>(Reuse.Singleton);
44-
45-
return container;
46-
}
47-
4829
/// <summary>
4930
/// Registers a seeded context factory and an <see cref="ISeededCommandDispatcher{TContext,TSeed}" />
5031
/// singleton over the existing <see cref="ICommandDispatcher{TContext}" /> (which must already be

src/SquidStd.Services.Core/README.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ dotnet add package SquidStd.Services.Core
2626
- One-line bootstrap: `container.RegisterCoreServices()` registers the full default service set.
2727
- `ConfigManagerService` — loads/saves YAML config sections and substitutes `$ENV_VAR` tokens.
2828
- `EventBusService` — in-process publish/subscribe over `IEvent`.
29-
- `CommandDispatcher<TContext>` — typed protocol command dispatch with fan-out, fault isolation, and a `CommandDispatchResult` (`RegisterCommandDispatcher` / `RegisterCommandHandler` / `RegisterCommandContextFactory`).
29+
- `CommandDispatcher<TContext>` — typed protocol command dispatch with fan-out, fault isolation, and a `CommandDispatchResult` (`RegisterCommandDispatcher` / `RegisterCommandHandler` / `RegisterSeededCommandDispatcher`).
3030
- `JobSystemService` — background job execution; `TimerWheelService` + cron scheduling for timed work.
3131
- `MainThreadDispatcherService` — marshal work back onto a main thread.
3232
- `MetricsCollectionService` — aggregates `IMetricProvider` samples.
@@ -48,13 +48,12 @@ container.RegisterCoreServices("squidstd", Directory.GetCurrentDirectory());
4848
## Command dispatch
4949

5050
```csharp
51-
// Register a dispatcher for your context type, a context factory, and handlers.
52-
container.RegisterCommandContextFactory<Session, CurrentSessionFactory>();
51+
// Register a dispatcher for your context type and handlers.
5352
container.RegisterCommandDispatcher<Session>();
5453
container.RegisterCommandHandler<PingCommand, Session, PingHandler>();
5554

56-
// Dispatch (handlers auto-subscribed at bootstrap). Build the context from the factory:
57-
var result = await dispatcher.DispatchAsync(new PingCommand("hi"));
55+
// Dispatch (handlers auto-subscribed at bootstrap). Pass the context explicitly:
56+
var result = await dispatcher.DispatchAsync(new PingCommand("hi"), session);
5857
if (!result.Matched)
5958
{
6059
// unknown command

src/SquidStd.Services.Core/Services/CommandDispatcher.cs

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,9 @@ namespace SquidStd.Services.Core.Services;
1515
public sealed class CommandDispatcher<TContext> : ICommandDispatcher<TContext>, IDisposable
1616
{
1717
private readonly ConcurrentDictionary<Type, List<object>> _handlers = new();
18-
private readonly ICommandContextFactory<TContext>? _contextFactory;
1918
private readonly ILogger _logger = Log.ForContext<CommandDispatcher<TContext>>();
2019
private bool _disposed;
2120

22-
/// <summary>Initializes the dispatcher without a context factory.</summary>
23-
public CommandDispatcher()
24-
: this(null)
25-
{
26-
}
27-
28-
/// <summary>Initializes the dispatcher with an optional context factory.</summary>
29-
/// <param name="contextFactory">Factory used by the context-less dispatch overload, if any.</param>
30-
public CommandDispatcher(ICommandContextFactory<TContext>? contextFactory)
31-
{
32-
_contextFactory = contextFactory;
33-
}
34-
3521
/// <inheritdoc />
3622
public IDisposable RegisterHandler<TCommand>(ICommandHandler<TCommand, TContext> handler)
3723
where TCommand : ICommand
@@ -78,22 +64,6 @@ public async Task<CommandDispatchResult> DispatchAsync<TCommand>(
7864
return new CommandDispatchResult(true, handlers.Length, errors);
7965
}
8066

81-
/// <inheritdoc />
82-
public Task<CommandDispatchResult> DispatchAsync<TCommand>(
83-
TCommand command, CancellationToken cancellationToken = default
84-
)
85-
where TCommand : ICommand
86-
{
87-
if (_contextFactory is null)
88-
{
89-
throw new InvalidOperationException(
90-
"No ICommandContextFactory<TContext> is registered; pass an explicit context instead."
91-
);
92-
}
93-
94-
return DispatchAsync(command, _contextFactory.Create(), cancellationToken);
95-
}
96-
9767
private CommandSubscription Add(Type commandType, object handler)
9868
{
9969
ThrowIfDisposed();

tests/SquidStd.Tests/Services/Core/CommandDispatcherTests.cs

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -111,32 +111,6 @@ public async Task Subscribe_DeliversToDelegate()
111111
Assert.Same(session, seenContext);
112112
}
113113

114-
[Fact]
115-
public async Task DispatchAsync_WithoutContext_UsesFactory()
116-
{
117-
var factory = new CountingFactory();
118-
using var dispatcher = new CommandDispatcher<Session>(factory);
119-
var handler = new RecordingHandler();
120-
dispatcher.RegisterHandler(handler);
121-
122-
var result = await dispatcher.DispatchAsync(new PingCommand("auto"));
123-
124-
Assert.True(result.Matched);
125-
Assert.Equal("auto", handler.LastText);
126-
Assert.Same(factory.Last, handler.LastContext);
127-
Assert.Equal(1, factory.CreateCount);
128-
}
129-
130-
[Fact]
131-
public async Task DispatchAsync_WithoutContext_WhenNoFactory_Throws()
132-
{
133-
using var dispatcher = new CommandDispatcher<Session>();
134-
135-
await Assert.ThrowsAsync<InvalidOperationException>(
136-
() => dispatcher.DispatchAsync(new PingCommand("x"))
137-
);
138-
}
139-
140114
private sealed class Session
141115
{
142116
}
@@ -165,19 +139,4 @@ public Task HandleAsync(PingCommand command, Session context, CancellationToken
165139
throw new InvalidOperationException("Synthetic failure.");
166140
}
167141
}
168-
169-
private sealed class CountingFactory : ICommandContextFactory<Session>
170-
{
171-
public int CreateCount { get; private set; }
172-
173-
public Session? Last { get; private set; }
174-
175-
public Session Create()
176-
{
177-
CreateCount++;
178-
Last = new Session();
179-
180-
return Last;
181-
}
182-
}
183142
}

tests/SquidStd.Tests/Services/Core/RegisterCommandDispatcherExtensionsTests.cs

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,6 @@ public async Task Activator_SubscribesRegisteredHandlers()
4040
Assert.Equal("hi", container.Resolve<PingHandler>().LastText);
4141
}
4242

43-
[Fact]
44-
public async Task RegisterCommandContextFactory_IsUsedByContextlessDispatch()
45-
{
46-
using var container = new Container();
47-
container.RegisterCommandContextFactory<Session, SessionFactory>();
48-
container.RegisterCommandDispatcher<Session>();
49-
container.RegisterCommandHandler<PingCommand, Session, PingHandler>();
50-
51-
await container.Resolve<CommandDispatcherActivator<Session>>().StartAsync(CancellationToken.None);
52-
53-
var dispatcher = container.Resolve<ICommandDispatcher<Session>>();
54-
var result = await dispatcher.DispatchAsync(new PingCommand("auto"));
55-
56-
Assert.True(result.Matched);
57-
Assert.Equal("auto", container.Resolve<PingHandler>().LastText);
58-
}
59-
6043
private sealed class Session
6144
{
6245
}
@@ -74,12 +57,4 @@ public Task HandleAsync(PingCommand command, Session context, CancellationToken
7457
return Task.CompletedTask;
7558
}
7659
}
77-
78-
private sealed class SessionFactory : ICommandContextFactory<Session>
79-
{
80-
public Session Create()
81-
{
82-
return new Session();
83-
}
84-
}
8560
}

0 commit comments

Comments
 (0)