Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/articles/cqrs-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,32 @@ builder.Services.AddApplication(options =>
});
```

### Registering from multiple assemblies

`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:

```csharp
builder.Services.AddApplication(options =>
{
options.RegisterHandlerAssemblies(typeof(Program).Assembly, typeof(SomeDomainType).Assembly);
options.RegisterFluentValidationAssemblies(typeof(Program).Assembly, typeof(SomeDomainType).Assembly);
});
```

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`:

```csharp
// In an Infrastructure project's own registration extension:
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
services.AddHandlers(o => o.RegisterHandlerAssemblies(typeof(AddInfrastructureExtensions).Assembly));
services.AddFluentValidation(o => o.RegisterFluentValidationAssemblies(typeof(AddInfrastructureExtensions).Assembly));
return services;
}
```

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.

## Defining Commands and Handlers

```csharp
Expand Down
7 changes: 5 additions & 2 deletions src/Vulthil.SharedKernel.Application/ApplicationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ namespace Vulthil.SharedKernel.Application;
public class FluentValidationOptions
{
private readonly HashSet<Assembly> _fluentValidationAssemblies = [];

/// <summary>
/// Gets the assemblies registered for FluentValidation validator scanning.
/// Validators discovered in these assemblies are registered automatically.
/// </summary>
public IReadOnlyList<Assembly> FluentValidationAssemblies => _fluentValidationAssemblies.ToList().AsReadOnly();
public IReadOnlyList<Assembly> FluentValidationAssemblies { get; private set; } = [];

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

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

HandlerAssemblies = _handlerAssemblies.ToList().AsReadOnly();
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,19 @@ internal sealed partial class RequestLoggingPipelineBehavior<TRequest, TResponse
where TRequest : IRequest<TResponse>
where TResponse : Result
{
private static readonly string _requestName = typeof(TRequest).Name;
private readonly ILogger<RequestLoggingPipelineBehavior<TRequest, TResponse>> _logger = logger;

/// <inheritdoc />
public async Task<TResponse> HandleAsync(TRequest request, PipelineDelegate<TResponse> next, CancellationToken cancellationToken = default)
{
var requestName = typeof(TRequest).Name;

LogProcessingRequest(_logger, requestName);
LogProcessingRequest(_logger, _requestName);

var result = await next(cancellationToken);

if (result.IsSuccess)
{
LogCompletedRequest(_logger, requestName);
LogCompletedRequest(_logger, _requestName);
}
else
{
Expand All @@ -37,7 +36,7 @@ public async Task<TResponse> HandleAsync(TRequest request, PipelineDelegate<TRes
["Error"] = JsonSerializer.Serialize(result.Error, result.Error.GetType())
}))
{
LogCompletedRequestWithError(_logger, requestName);
LogCompletedRequestWithError(_logger, _requestName);
}
}

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

/// <inheritdoc />
public async Task HandleAsync(TDomainEvent domainEvent, DomainEventPipelineDelegate next, CancellationToken cancellationToken = default)
{
var domainEventName = typeof(TDomainEvent).Name;

LogProcessingEvent(_logger, domainEventName);
LogProcessingEvent(_logger, _domainEventName);

await next(cancellationToken);

LogCompletedEvent(_logger, domainEventName);
LogCompletedEvent(_logger, _domainEventName);
}


Expand Down
53 changes: 53 additions & 0 deletions src/Vulthil.SharedKernel.Application/CompatibilitySuppressions.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<!-- Maintainer-approved removal of three trap overloads: the parameterless AddApplication() and
AddHandlers() always threw InvalidOperationException (they register zero handler assemblies), and
AddFluentValidation() silently registered no validators (it scans zero assemblies). No external
consumer of this package exists yet, so the maintainer approved removing them outright instead of
the usual deprecate-then-remove cycle. The working replacement for all three is the
Action<...>-configured overload of the same name. PublicAPI.Unshipped.txt carries the matching
*REMOVED* markers so PublicApiAnalyzer accepts the removal from source. -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddApplication(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
<Left>lib/net10.0/Vulthil.SharedKernel.Application.dll</Left>
<Right>lib/net10.0/Vulthil.SharedKernel.Application.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddFluentValidation(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
<Left>lib/net10.0/Vulthil.SharedKernel.Application.dll</Left>
<Right>lib/net10.0/Vulthil.SharedKernel.Application.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddHandlers(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
<Left>lib/net10.0/Vulthil.SharedKernel.Application.dll</Left>
<Right>lib/net10.0/Vulthil.SharedKernel.Application.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddApplication(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
<Left>lib/net9.0/Vulthil.SharedKernel.Application.dll</Left>
<Right>lib/net9.0/Vulthil.SharedKernel.Application.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddFluentValidation(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
<Left>lib/net9.0/Vulthil.SharedKernel.Application.dll</Left>
<Right>lib/net9.0/Vulthil.SharedKernel.Application.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Vulthil.SharedKernel.Application.DependencyInjection.AddHandlers(Microsoft.Extensions.DependencyInjection.IServiceCollection)</Target>
<Left>lib/net9.0/Vulthil.SharedKernel.Application.dll</Left>
<Right>lib/net9.0/Vulthil.SharedKernel.Application.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
61 changes: 36 additions & 25 deletions src/Vulthil.SharedKernel.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,22 @@ namespace Vulthil.SharedKernel.Application;
public static class DependencyInjection
{

/// <summary>
/// Registers application-layer services with default options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddApplication(this IServiceCollection services) => services.AddApplication(new ApplicationOptions());

/// <summary>
/// Registers application-layer services including handlers and FluentValidation validators.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="applicationOptionsAction">An action to configure application options.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// This 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 — via <see cref="ApplicationOptions.RegisterHandlerAssemblies"/> and
/// <see cref="ApplicationOptions.RegisterFluentValidationAssemblies"/>. A project that does not own the host
/// (for example, an Infrastructure project's own registration extension) can instead call
/// <see cref="AddHandlers(IServiceCollection, Action{HandlerOptions})"/> and/or
/// <see cref="AddFluentValidation(IServiceCollection, Action{FluentValidationOptions})"/> directly to register
/// that project's own assemblies additively, without re-calling this method.
/// </remarks>
public static IServiceCollection AddApplication(this IServiceCollection services, Action<ApplicationOptions> applicationOptionsAction)
{
var applicationOptions = new ApplicationOptions();
Expand All @@ -47,20 +50,20 @@ public static IServiceCollection AddApplication(this IServiceCollection services
return services;
}

/// <summary>
/// Registers FluentValidation validators with default options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFluentValidation(this IServiceCollection services) => services.AddFluentValidation(new FluentValidationOptions());


/// <summary>
/// Registers FluentValidation validators from the configured assemblies.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="fluentValidationOptionsAction">An optional action to configure validation options.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// A supported modular entry point: call this from a project that does not own the host (for example, an
/// Infrastructure project's own registration extension) to additively register that project's own
/// FluentValidation validator assemblies without re-calling
/// <see cref="AddApplication(IServiceCollection, Action{ApplicationOptions})"/>. Registration only scans the
/// assemblies configured in <paramref name="fluentValidationOptionsAction"/>, so calling this from multiple
/// independent modules composes additively instead of conflicting.
/// </remarks>
public static IServiceCollection AddFluentValidation(this IServiceCollection services, Action<FluentValidationOptions> fluentValidationOptionsAction)
{
var fluentValidationOptions = new FluentValidationOptions();
Expand All @@ -84,19 +87,20 @@ public static IServiceCollection AddFluentValidation(this IServiceCollection ser
return services;
}

/// <summary>
/// Registers request handlers, domain event handlers, and pipeline handlers with default options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddHandlers(this IServiceCollection services) => services.AddHandlers(new HandlerOptions());

/// <summary>
/// Registers request handlers, domain event handlers, and pipeline handlers from the configured assemblies.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="handlerOptionsAction">An optional action to configure handler options.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// A supported modular entry point: call this from a project that does not own the host (for example, an
/// Infrastructure project's own registration extension) to additively register that project's own handler
/// assemblies without re-calling <see cref="AddApplication(IServiceCollection, Action{ApplicationOptions})"/>.
/// Registration only scans the assemblies configured in <paramref name="handlerOptionsAction"/>, and the
/// shared <see cref="ISender"/>/<see cref="IDomainEventPublisher"/> registrations use <c>TryAddScoped</c>, so
/// calling this from multiple independent modules composes additively instead of conflicting.
/// </remarks>
public static IServiceCollection AddHandlers(this IServiceCollection services, Action<HandlerOptions> handlerOptionsAction)
{
var handlerOptions = new HandlerOptions();
Expand All @@ -111,12 +115,19 @@ public static IServiceCollection AddHandlers(this IServiceCollection services, A
/// <param name="services">The service collection.</param>
/// <param name="handlerOptions">The pre-configured handler options.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// The composition primitive both <see cref="AddApplication(IServiceCollection, Action{ApplicationOptions})"/>
/// and <see cref="AddHandlers(IServiceCollection, Action{HandlerOptions})"/> delegate to. Safe to call from
/// multiple independent modules: registration only scans <see cref="HandlerOptions.HandlerAssemblies"/>, and
/// the shared <see cref="ISender"/>/<see cref="IDomainEventPublisher"/> registrations use
/// <c>TryAddScoped</c>, so calling it more than once composes additively instead of conflicting.
/// </remarks>
/// <exception cref="InvalidOperationException">Thrown when no handler assemblies have been registered.</exception>
public static IServiceCollection AddHandlers(this IServiceCollection services, HandlerOptions handlerOptions)
{
if (handlerOptions.HandlerAssemblies.Count == 0)
{
throw new InvalidOperationException($"Must add atleast one assembly, by using the {nameof(HandlerOptions.RegisterHandlerAssemblies)} method.");
throw new InvalidOperationException($"Must add at least one assembly, by using the {nameof(HandlerOptions.RegisterHandlerAssemblies)} method.");
}

services.TryAddScoped<IDomainEventPublisher, DomainEventPublisher>();
Expand All @@ -135,8 +146,8 @@ public static IServiceCollection AddHandlers(this IServiceCollection services, H
/// <summary>
/// Registers an open-generic request pipeline behavior. Behaviors registered through this method
/// apply to every handler resolved after <see cref="IServiceProvider"/> construction — order of
/// registration relative to <see cref="AddHandlers(IServiceCollection)"/> is irrelevant because
/// behaviors are composed lazily at handler-resolution time.
/// registration relative to <see cref="AddApplication(IServiceCollection, Action{ApplicationOptions})"/>
/// is irrelevant because behaviors are composed lazily at handler-resolution time.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="pipelineHandler">The open-generic type implementing <see cref="IPipelineHandler{TRequest, TResponse}"/>.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ internal sealed class DomainEventPublisher(IServiceProvider serviceProvider) : I

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

public Task PublishAsync(object notification, CancellationToken cancellationToken) =>
public Task PublishAsync(object notification, CancellationToken cancellationToken = default) =>
notification switch
{
IDomainEvent instance => PublishAsync(instance, cancellationToken),
null => throw new ArgumentNullException(nameof(notification)),
_ => throw new ArgumentException($"{nameof(notification)} does not implement {nameof(IDomainEvent)}")
};

public Task PublishAsync<TNotification>(TNotification notification, CancellationToken cancellationToken)
public Task PublishAsync<TNotification>(TNotification notification, CancellationToken cancellationToken = default)
where TNotification : IDomainEvent =>
notification is null
? throw new ArgumentNullException(nameof(notification))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ public interface IDomainEventPublisher
/// </summary>
/// <param name="notification">The domain event to publish.</param>
/// <param name="cancellationToken">A token to observe for cancellation.</param>
Task PublishAsync(object notification, CancellationToken cancellationToken);
Task PublishAsync(object notification, CancellationToken cancellationToken = default);

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