Skip to content

Commit c5ec1dc

Browse files
authored
Clean up the Application package's DI surface (#347)
* refactor(application): obsolete trap DI overloads, drop dead internal plumbing, and hoist pipeline allocations * refactor(application): remove trap DI overloads instead of obsoleting them, and document the two-level registration model
1 parent 8c72721 commit c5ec1dc

14 files changed

Lines changed: 308 additions & 68 deletions

docs/articles/cqrs-pipeline.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,32 @@ builder.Services.AddApplication(options =>
3232
});
3333
```
3434

35+
### Registering from multiple assemblies
36+
37+
`AddApplication(Action<ApplicationOptions>)` is the host's application-composition entry point. Call it once from the project that owns the composition root, and register handler and validator assemblies from anywhere in the solution — including assemblies owned by other projects:
38+
39+
```csharp
40+
builder.Services.AddApplication(options =>
41+
{
42+
options.RegisterHandlerAssemblies(typeof(Program).Assembly, typeof(SomeDomainType).Assembly);
43+
options.RegisterFluentValidationAssemblies(typeof(Program).Assembly, typeof(SomeDomainType).Assembly);
44+
});
45+
```
46+
47+
A project that does not own the host — for example, an Infrastructure project with its own `AddInfrastructure` extension — can instead call `AddHandlers(Action<HandlerOptions>)` and `AddFluentValidation(Action<FluentValidationOptions>)` directly, registering its own assemblies additively without re-calling `AddApplication`:
48+
49+
```csharp
50+
// In an Infrastructure project's own registration extension:
51+
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
52+
{
53+
services.AddHandlers(o => o.RegisterHandlerAssemblies(typeof(AddInfrastructureExtensions).Assembly));
54+
services.AddFluentValidation(o => o.RegisterFluentValidationAssemblies(typeof(AddInfrastructureExtensions).Assembly));
55+
return services;
56+
}
57+
```
58+
59+
Both entry points compose safely: `ISender` and `IDomainEventPublisher` are registered with `TryAddScoped`, so calling `AddHandlers` from more than one module never duplicates or conflicts with the host's own registration — each call only scans the assemblies it was given, and handlers discovered by every call resolve side by side. The same goes for validation types: a module's `AddFluentValidation` call registers only its own validators, additively alongside whatever the host or any other module already registered.
60+
3561
## Defining Commands and Handlers
3662

3763
```csharp

src/Vulthil.SharedKernel.Application/ApplicationOptions.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ namespace Vulthil.SharedKernel.Application;
1111
public class FluentValidationOptions
1212
{
1313
private readonly HashSet<Assembly> _fluentValidationAssemblies = [];
14+
1415
/// <summary>
1516
/// Gets the assemblies registered for FluentValidation validator scanning.
1617
/// Validators discovered in these assemblies are registered automatically.
1718
/// </summary>
18-
public IReadOnlyList<Assembly> FluentValidationAssemblies => _fluentValidationAssemblies.ToList().AsReadOnly();
19+
public IReadOnlyList<Assembly> FluentValidationAssemblies { get; private set; } = [];
1920

2021
/// <summary>
2122
/// Registers assemblies to scan for FluentValidation validators.
@@ -29,6 +30,7 @@ public FluentValidationOptions RegisterFluentValidationAssemblies(params Assembl
2930
_fluentValidationAssemblies.Add(item);
3031
}
3132

33+
FluentValidationAssemblies = _fluentValidationAssemblies.ToList().AsReadOnly();
3234
return this;
3335
}
3436
}
@@ -43,7 +45,7 @@ public class HandlerOptions
4345
/// Gets the assemblies registered for handler scanning.
4446
/// Request handlers and domain event handlers in these assemblies are registered automatically.
4547
/// </summary>
46-
public IReadOnlyList<Assembly> HandlerAssemblies => _handlerAssemblies.ToList().AsReadOnly();
48+
public IReadOnlyList<Assembly> HandlerAssemblies { get; private set; } = [];
4749
/// <summary>
4850
/// Gets the pipeline handler service descriptors registered via <see cref="AddOpenPipelineHandler"/> or <see cref="AddOpenDomainEventPipelineHandler"/>.
4951
/// </summary>
@@ -60,6 +62,7 @@ public HandlerOptions RegisterHandlerAssemblies(params Assembly[] assemblies)
6062
_handlerAssemblies.Add(assembly);
6163
}
6264

65+
HandlerAssemblies = _handlerAssemblies.ToList().AsReadOnly();
6366
return this;
6467
}
6568

src/Vulthil.SharedKernel.Application/Behaviors/RequestLoggingPipelineBehavior.cs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,19 @@ internal sealed partial class RequestLoggingPipelineBehavior<TRequest, TResponse
1515
where TRequest : IRequest<TResponse>
1616
where TResponse : Result
1717
{
18+
private static readonly string _requestName = typeof(TRequest).Name;
1819
private readonly ILogger<RequestLoggingPipelineBehavior<TRequest, TResponse>> _logger = logger;
1920

2021
/// <inheritdoc />
2122
public async Task<TResponse> HandleAsync(TRequest request, PipelineDelegate<TResponse> next, CancellationToken cancellationToken = default)
2223
{
23-
var requestName = typeof(TRequest).Name;
24-
25-
LogProcessingRequest(_logger, requestName);
24+
LogProcessingRequest(_logger, _requestName);
2625

2726
var result = await next(cancellationToken);
2827

2928
if (result.IsSuccess)
3029
{
31-
LogCompletedRequest(_logger, requestName);
30+
LogCompletedRequest(_logger, _requestName);
3231
}
3332
else
3433
{
@@ -37,7 +36,7 @@ public async Task<TResponse> HandleAsync(TRequest request, PipelineDelegate<TRes
3736
["Error"] = JsonSerializer.Serialize(result.Error, result.Error.GetType())
3837
}))
3938
{
40-
LogCompletedRequestWithError(_logger, requestName);
39+
LogCompletedRequestWithError(_logger, _requestName);
4140
}
4241
}
4342

@@ -58,18 +57,17 @@ internal sealed partial class DomainEventLoggingPipelineBehavior<TDomainEvent>(
5857
: IDomainEventPipelineHandler<TDomainEvent>
5958
where TDomainEvent : IDomainEvent
6059
{
60+
private static readonly string _domainEventName = typeof(TDomainEvent).Name;
6161
private readonly ILogger<DomainEventLoggingPipelineBehavior<TDomainEvent>> _logger = logger;
6262

6363
/// <inheritdoc />
6464
public async Task HandleAsync(TDomainEvent domainEvent, DomainEventPipelineDelegate next, CancellationToken cancellationToken = default)
6565
{
66-
var domainEventName = typeof(TDomainEvent).Name;
67-
68-
LogProcessingEvent(_logger, domainEventName);
66+
LogProcessingEvent(_logger, _domainEventName);
6967

7068
await next(cancellationToken);
7169

72-
LogCompletedEvent(_logger, domainEventName);
70+
LogCompletedEvent(_logger, _domainEventName);
7371
}
7472

7573

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
3+
<!-- Maintainer-approved removal of three trap overloads: the parameterless AddApplication() and
4+
AddHandlers() always threw InvalidOperationException (they register zero handler assemblies), and
5+
AddFluentValidation() silently registered no validators (it scans zero assemblies). No external
6+
consumer of this package exists yet, so the maintainer approved removing them outright instead of
7+
the usual deprecate-then-remove cycle. The working replacement for all three is the
8+
Action<...>-configured overload of the same name. PublicAPI.Unshipped.txt carries the matching
9+
*REMOVED* markers so PublicApiAnalyzer accepts the removal from source. -->
10+
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
11+
<Suppression>
12+
<DiagnosticId>CP0002</DiagnosticId>
13+
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddApplication(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
14+
<Left>lib/net10.0/Vulthil.SharedKernel.Application.dll</Left>
15+
<Right>lib/net10.0/Vulthil.SharedKernel.Application.dll</Right>
16+
<IsBaselineSuppression>true</IsBaselineSuppression>
17+
</Suppression>
18+
<Suppression>
19+
<DiagnosticId>CP0002</DiagnosticId>
20+
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddFluentValidation(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
21+
<Left>lib/net10.0/Vulthil.SharedKernel.Application.dll</Left>
22+
<Right>lib/net10.0/Vulthil.SharedKernel.Application.dll</Right>
23+
<IsBaselineSuppression>true</IsBaselineSuppression>
24+
</Suppression>
25+
<Suppression>
26+
<DiagnosticId>CP0002</DiagnosticId>
27+
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddHandlers(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
28+
<Left>lib/net10.0/Vulthil.SharedKernel.Application.dll</Left>
29+
<Right>lib/net10.0/Vulthil.SharedKernel.Application.dll</Right>
30+
<IsBaselineSuppression>true</IsBaselineSuppression>
31+
</Suppression>
32+
<Suppression>
33+
<DiagnosticId>CP0002</DiagnosticId>
34+
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddApplication(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
35+
<Left>lib/net9.0/Vulthil.SharedKernel.Application.dll</Left>
36+
<Right>lib/net9.0/Vulthil.SharedKernel.Application.dll</Right>
37+
<IsBaselineSuppression>true</IsBaselineSuppression>
38+
</Suppression>
39+
<Suppression>
40+
<DiagnosticId>CP0002</DiagnosticId>
41+
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddFluentValidation(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
42+
<Left>lib/net9.0/Vulthil.SharedKernel.Application.dll</Left>
43+
<Right>lib/net9.0/Vulthil.SharedKernel.Application.dll</Right>
44+
<IsBaselineSuppression>true</IsBaselineSuppression>
45+
</Suppression>
46+
<Suppression>
47+
<DiagnosticId>CP0002</DiagnosticId>
48+
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddHandlers(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
49+
<Left>lib/net9.0/Vulthil.SharedKernel.Application.dll</Left>
50+
<Right>lib/net9.0/Vulthil.SharedKernel.Application.dll</Right>
51+
<IsBaselineSuppression>true</IsBaselineSuppression>
52+
</Suppression>
53+
</Suppressions>

src/Vulthil.SharedKernel.Application/DependencyInjection.cs

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,22 @@ namespace Vulthil.SharedKernel.Application;
1313
public static class DependencyInjection
1414
{
1515

16-
/// <summary>
17-
/// Registers application-layer services with default options.
18-
/// </summary>
19-
/// <param name="services">The service collection.</param>
20-
/// <returns>The service collection for chaining.</returns>
21-
public static IServiceCollection AddApplication(this IServiceCollection services) => services.AddApplication(new ApplicationOptions());
22-
2316
/// <summary>
2417
/// Registers application-layer services including handlers and FluentValidation validators.
2518
/// </summary>
2619
/// <param name="services">The service collection.</param>
2720
/// <param name="applicationOptionsAction">An action to configure application options.</param>
2821
/// <returns>The service collection for chaining.</returns>
22+
/// <remarks>
23+
/// This is the host's application-composition entry point: call it once from the project that owns the
24+
/// composition root, and register handler and validator assemblies from anywhere in the solution — including
25+
/// assemblies owned by other projects — via <see cref="ApplicationOptions.RegisterHandlerAssemblies"/> and
26+
/// <see cref="ApplicationOptions.RegisterFluentValidationAssemblies"/>. A project that does not own the host
27+
/// (for example, an Infrastructure project's own registration extension) can instead call
28+
/// <see cref="AddHandlers(IServiceCollection, Action{HandlerOptions})"/> and/or
29+
/// <see cref="AddFluentValidation(IServiceCollection, Action{FluentValidationOptions})"/> directly to register
30+
/// that project's own assemblies additively, without re-calling this method.
31+
/// </remarks>
2932
public static IServiceCollection AddApplication(this IServiceCollection services, Action<ApplicationOptions> applicationOptionsAction)
3033
{
3134
var applicationOptions = new ApplicationOptions();
@@ -47,20 +50,20 @@ public static IServiceCollection AddApplication(this IServiceCollection services
4750
return services;
4851
}
4952

50-
/// <summary>
51-
/// Registers FluentValidation validators with default options.
52-
/// </summary>
53-
/// <param name="services">The service collection.</param>
54-
/// <returns>The service collection for chaining.</returns>
55-
public static IServiceCollection AddFluentValidation(this IServiceCollection services) => services.AddFluentValidation(new FluentValidationOptions());
56-
57-
5853
/// <summary>
5954
/// Registers FluentValidation validators from the configured assemblies.
6055
/// </summary>
6156
/// <param name="services">The service collection.</param>
6257
/// <param name="fluentValidationOptionsAction">An optional action to configure validation options.</param>
6358
/// <returns>The service collection for chaining.</returns>
59+
/// <remarks>
60+
/// A supported modular entry point: call this from a project that does not own the host (for example, an
61+
/// Infrastructure project's own registration extension) to additively register that project's own
62+
/// FluentValidation validator assemblies without re-calling
63+
/// <see cref="AddApplication(IServiceCollection, Action{ApplicationOptions})"/>. Registration only scans the
64+
/// assemblies configured in <paramref name="fluentValidationOptionsAction"/>, so calling this from multiple
65+
/// independent modules composes additively instead of conflicting.
66+
/// </remarks>
6467
public static IServiceCollection AddFluentValidation(this IServiceCollection services, Action<FluentValidationOptions> fluentValidationOptionsAction)
6568
{
6669
var fluentValidationOptions = new FluentValidationOptions();
@@ -84,19 +87,20 @@ public static IServiceCollection AddFluentValidation(this IServiceCollection ser
8487
return services;
8588
}
8689

87-
/// <summary>
88-
/// Registers request handlers, domain event handlers, and pipeline handlers with default options.
89-
/// </summary>
90-
/// <param name="services">The service collection.</param>
91-
/// <returns>The service collection for chaining.</returns>
92-
public static IServiceCollection AddHandlers(this IServiceCollection services) => services.AddHandlers(new HandlerOptions());
93-
9490
/// <summary>
9591
/// Registers request handlers, domain event handlers, and pipeline handlers from the configured assemblies.
9692
/// </summary>
9793
/// <param name="services">The service collection.</param>
9894
/// <param name="handlerOptionsAction">An optional action to configure handler options.</param>
9995
/// <returns>The service collection for chaining.</returns>
96+
/// <remarks>
97+
/// A supported modular entry point: call this from a project that does not own the host (for example, an
98+
/// Infrastructure project's own registration extension) to additively register that project's own handler
99+
/// assemblies without re-calling <see cref="AddApplication(IServiceCollection, Action{ApplicationOptions})"/>.
100+
/// Registration only scans the assemblies configured in <paramref name="handlerOptionsAction"/>, and the
101+
/// shared <see cref="ISender"/>/<see cref="IDomainEventPublisher"/> registrations use <c>TryAddScoped</c>, so
102+
/// calling this from multiple independent modules composes additively instead of conflicting.
103+
/// </remarks>
100104
public static IServiceCollection AddHandlers(this IServiceCollection services, Action<HandlerOptions> handlerOptionsAction)
101105
{
102106
var handlerOptions = new HandlerOptions();
@@ -111,12 +115,19 @@ public static IServiceCollection AddHandlers(this IServiceCollection services, A
111115
/// <param name="services">The service collection.</param>
112116
/// <param name="handlerOptions">The pre-configured handler options.</param>
113117
/// <returns>The service collection for chaining.</returns>
118+
/// <remarks>
119+
/// The composition primitive both <see cref="AddApplication(IServiceCollection, Action{ApplicationOptions})"/>
120+
/// and <see cref="AddHandlers(IServiceCollection, Action{HandlerOptions})"/> delegate to. Safe to call from
121+
/// multiple independent modules: registration only scans <see cref="HandlerOptions.HandlerAssemblies"/>, and
122+
/// the shared <see cref="ISender"/>/<see cref="IDomainEventPublisher"/> registrations use
123+
/// <c>TryAddScoped</c>, so calling it more than once composes additively instead of conflicting.
124+
/// </remarks>
114125
/// <exception cref="InvalidOperationException">Thrown when no handler assemblies have been registered.</exception>
115126
public static IServiceCollection AddHandlers(this IServiceCollection services, HandlerOptions handlerOptions)
116127
{
117128
if (handlerOptions.HandlerAssemblies.Count == 0)
118129
{
119-
throw new InvalidOperationException($"Must add atleast one assembly, by using the {nameof(HandlerOptions.RegisterHandlerAssemblies)} method.");
130+
throw new InvalidOperationException($"Must add at least one assembly, by using the {nameof(HandlerOptions.RegisterHandlerAssemblies)} method.");
120131
}
121132

122133
services.TryAddScoped<IDomainEventPublisher, DomainEventPublisher>();
@@ -135,8 +146,8 @@ public static IServiceCollection AddHandlers(this IServiceCollection services, H
135146
/// <summary>
136147
/// Registers an open-generic request pipeline behavior. Behaviors registered through this method
137148
/// apply to every handler resolved after <see cref="IServiceProvider"/> construction — order of
138-
/// registration relative to <see cref="AddHandlers(IServiceCollection)"/> is irrelevant because
139-
/// behaviors are composed lazily at handler-resolution time.
149+
/// registration relative to <see cref="AddApplication(IServiceCollection, Action{ApplicationOptions})"/>
150+
/// is irrelevant because behaviors are composed lazily at handler-resolution time.
140151
/// </summary>
141152
/// <param name="services">The service collection.</param>
142153
/// <param name="pipelineHandler">The open-generic type implementing <see cref="IPipelineHandler{TRequest, TResponse}"/>.</param>

src/Vulthil.SharedKernel.Application/Messaging/DomainEvents/DomainEventPublisher.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ internal sealed class DomainEventPublisher(IServiceProvider serviceProvider) : I
99

1010
private static readonly ConcurrentDictionary<Type, INotificationHandlerWrapper> _notificationHandlers = new();
1111

12-
public Task PublishAsync(object notification, CancellationToken cancellationToken) =>
12+
public Task PublishAsync(object notification, CancellationToken cancellationToken = default) =>
1313
notification switch
1414
{
1515
IDomainEvent instance => PublishAsync(instance, cancellationToken),
1616
null => throw new ArgumentNullException(nameof(notification)),
1717
_ => throw new ArgumentException($"{nameof(notification)} does not implement {nameof(IDomainEvent)}")
1818
};
1919

20-
public Task PublishAsync<TNotification>(TNotification notification, CancellationToken cancellationToken)
20+
public Task PublishAsync<TNotification>(TNotification notification, CancellationToken cancellationToken = default)
2121
where TNotification : IDomainEvent =>
2222
notification is null
2323
? throw new ArgumentNullException(nameof(notification))

src/Vulthil.SharedKernel.Application/Messaging/DomainEvents/IDomainEventPublisher.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ public interface IDomainEventPublisher
1212
/// </summary>
1313
/// <param name="notification">The domain event to publish.</param>
1414
/// <param name="cancellationToken">A token to observe for cancellation.</param>
15-
Task PublishAsync(object notification, CancellationToken cancellationToken);
15+
Task PublishAsync(object notification, CancellationToken cancellationToken = default);
1616

1717
/// <summary>
1818
/// Publishes a strongly-typed domain event to all registered handlers.
1919
/// </summary>
2020
/// <typeparam name="TNotification">The type of domain event to publish.</typeparam>
2121
/// <param name="notification">The domain event to publish.</param>
2222
/// <param name="cancellationToken">A token to observe for cancellation.</param>
23-
Task PublishAsync<TNotification>(TNotification notification, CancellationToken cancellationToken) where TNotification : IDomainEvent;
23+
Task PublishAsync<TNotification>(TNotification notification, CancellationToken cancellationToken = default) where TNotification : IDomainEvent;
2424
}

0 commit comments

Comments
 (0)