diff --git a/.claude/skills/simplemodule/references/cross-module.md b/.claude/skills/simplemodule/references/cross-module.md
index 8b81cb06..121359e5 100644
--- a/.claude/skills/simplemodule/references/cross-module.md
+++ b/.claude/skills/simplemodule/references/cross-module.md
@@ -34,9 +34,11 @@ Other modules reference the Contracts project and inject `IProductContracts`:
```
-## Event Bus
+## Event Bus (Wolverine)
-For decoupled cross-module communication without direct dependencies:
+For decoupled cross-module communication without direct dependencies. The
+framework uses [WolverineFx](https://wolverinefx.net/) for in-process
+messaging; inject `IMessageBus` from `Wolverine`.
### Define Events (in Contracts)
@@ -44,44 +46,57 @@ For decoupled cross-module communication without direct dependencies:
public record OrderCreatedEvent(OrderId OrderId, UserId UserId, decimal Total) : IEvent;
```
+`IEvent` is a marker interface in `SimpleModule.Core.Events` — it costs
+nothing and makes "this type is a domain event" visible at the type level.
+
### Publish Events
```csharp
-public class OrderService
-{
- private readonly IEventBus _eventBus;
+using Wolverine;
+public class OrderService(OrdersDbContext db, IMessageBus bus)
+{
public async Task CreateOrderAsync(CreateOrderRequest request)
{
// ... create order ...
- // Synchronous: waits for all handlers, collects exceptions
- await _eventBus.PublishAsync(new OrderCreatedEvent(order.Id, order.UserId, order.Total));
+ // Fire-and-forget: enqueues on the local queue, returns immediately.
+ // Handler failures are isolated per handler chain.
+ await bus.PublishAsync(new OrderCreatedEvent(order.Id, order.UserId, order.Total));
- // Fire-and-forget: returns immediately
- _eventBus.PublishInBackground(new OrderCreatedEvent(order.Id, order.UserId, order.Total));
+ // Inline with response: waits for the handler, propagates the first failure.
+ await bus.InvokeAsync(new OrderCreatedEvent(order.Id, order.UserId, order.Total));
}
}
```
### Handle Events
+Wolverine auto-discovers handlers by convention: class name ending in
+`Handler` or `Consumer`, method named `Handle`/`HandleAsync`/`Consume`/
+`ConsumeAsync`, first parameter is the message type, remaining parameters
+are resolved from DI.
+
```csharp
-public class OrderCreatedHandler : IEventHandler
+public class OrderCreatedHandler(ILogger logger)
{
- public async Task HandleAsync(OrderCreatedEvent @event, CancellationToken cancellationToken)
+ public Task Handle(OrderCreatedEvent evt, CancellationToken ct)
{
// React to the event (e.g., send notification, update stats)
+ return Task.CompletedTask;
}
}
```
+No DI registration needed — Wolverine scans loaded assemblies at startup.
+Non-conventional classes can opt in with `[WolverineHandler]`.
+
### Event Semantics
-- All handlers execute sequentially in registration order
-- Handler failures are isolated — other handlers still run
-- After all handlers, collected exceptions throw as `AggregateException`
-- Make handlers idempotent when possible
-- For long-running work, use background jobs instead
+- Wolverine routes by runtime type (`message.GetType()`), not the compile-time `T`
+- Each handler chain runs in its own scope with its own exception isolation
+- For retry/error-queue policies, use `[RetryNow(...)]` or `chain.OnException(...)`
+- Make handlers idempotent when possible — messages may be re-run
+- For long-running work, prefer `IBackgroundJobs` or Wolverine's scheduled send
## Permissions
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ffda4f54..f41945d5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -63,6 +63,9 @@
+
+
+
diff --git a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs
index 1a998f49..c0f510ab 100644
--- a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs
+++ b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs
@@ -443,7 +443,7 @@ public string ServiceClass(string moduleName, string singularName)
// Keep only the DbContext param, remove cross-module and infrastructure deps
var crossModuleTypes = _otherModuleNames
.Select(m => $"I{GetSingularName(m)}")
- .Append("IEventBus")
+ .Append("IMessageBus")
.Append("ILogger<")
.ToList();
diff --git a/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs b/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs
index ca3c1329..810b237e 100644
--- a/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs
+++ b/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs
@@ -4,7 +4,7 @@ namespace SimpleModule.Core.Entities;
///
/// Aggregate root with audit tracking, soft delete, versioning, and domain events.
-/// Domain events are automatically dispatched via after SaveChanges.
+/// Domain events are automatically dispatched via Wolverine's IMessageBus after SaveChanges.
///
public abstract class AuditableAggregateRoot : FullAuditableEntity, IHasDomainEvents
{
diff --git a/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs b/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs
index ba0da6ad..d77ad308 100644
--- a/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs
+++ b/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs
@@ -4,7 +4,7 @@ namespace SimpleModule.Core.Entities;
///
/// Entities implementing this interface can raise domain events that are automatically
-/// dispatched via after a successful SaveChanges.
+/// dispatched via Wolverine's IMessageBus after a successful SaveChanges.
///
public interface IHasDomainEvents
{
diff --git a/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs b/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs
deleted file mode 100644
index 6fcaaa0d..00000000
--- a/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using System.Threading.Channels;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace SimpleModule.Core.Events;
-
-///
-/// Unbounded channel for queuing events to be dispatched by a background service.
-/// Register as a singleton. The reads from this channel.
-///
-public sealed partial class BackgroundEventChannel(ILogger logger)
-{
- private readonly Channel> _channel =
- Channel.CreateBounded>(
- new BoundedChannelOptions(10_000)
- {
- SingleReader = true,
- FullMode = BoundedChannelFullMode.DropWrite,
- }
- );
-
- internal ChannelReader> Reader =>
- _channel.Reader;
-
- internal void Enqueue(T @event)
- where T : IEvent
- {
- Func dispatch = (sp, ct) =>
- {
- var bus = sp.GetRequiredService();
- return bus.PublishAsync(@event, ct);
- };
-
- if (!_channel.Writer.TryWrite(dispatch))
- {
- LogEventDropped(logger, typeof(T).Name);
- }
- }
-
- [LoggerMessage(
- Level = LogLevel.Warning,
- Message = "Background event '{EventName}' dropped — channel full or closed"
- )]
- private static partial void LogEventDropped(ILogger logger, string eventName);
-}
diff --git a/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs b/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs
deleted file mode 100644
index 5c9af16e..00000000
--- a/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-
-namespace SimpleModule.Core.Events;
-
-///
-/// Background service that drains the and dispatches
-/// events to their handlers in a scoped DI context. Events are dispatched concurrently
-/// to avoid head-of-line blocking from slow handlers.
-///
-public sealed partial class BackgroundEventDispatcher(
- BackgroundEventChannel channel,
- IServiceScopeFactory scopeFactory,
- ILogger logger
-) : BackgroundService
-{
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- await foreach (var dispatch in channel.Reader.ReadAllAsync(stoppingToken))
- {
- _ = DispatchAsync(dispatch, stoppingToken);
- }
- }
-
- private async Task DispatchAsync(
- Func dispatch,
- CancellationToken stoppingToken
- )
- {
- try
- {
- using var scope = scopeFactory.CreateScope();
- await dispatch(scope.ServiceProvider, stoppingToken);
- }
-#pragma warning disable CA1031 // Background dispatcher must not crash on handler failures
- catch (Exception ex)
-#pragma warning restore CA1031
- {
- LogDispatchFailed(logger, ex);
- }
- }
-
- [LoggerMessage(Level = LogLevel.Error, Message = "Background event dispatch failed")]
- private static partial void LogDispatchFailed(ILogger logger, Exception exception);
-}
diff --git a/framework/SimpleModule.Core/Events/EventBus.cs b/framework/SimpleModule.Core/Events/EventBus.cs
deleted file mode 100644
index ebb6e3cf..00000000
--- a/framework/SimpleModule.Core/Events/EventBus.cs
+++ /dev/null
@@ -1,178 +0,0 @@
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace SimpleModule.Core.Events;
-
-///
-/// Publishes events to registered handlers with exception isolation semantics.
-///
-///
-///
-/// The EventBus is the central event distribution mechanism in the framework. It ensures reliable
-/// event delivery with partial success guarantees: even if one handler fails, other handlers will
-/// still execute. This prevents cascade failures across the application.
-///
-///
-/// Execution Semantics:
-///
-/// -
-///
-/// All registered handlers for an event execute sequentially in registration order,
-/// regardless of failures.
-///
-///
-/// -
-///
-/// If any handler throws an exception, it is caught and logged, then collected for later rethrow.
-///
-///
-/// -
-///
-/// After all handlers have executed, if any exceptions were collected, they are thrown as
-/// a single .
-///
-///
-/// -
-///
-/// Handler failures are isolated: a throwing handler cannot prevent subsequent handlers from executing.
-///
-///
-///
-///
-///
-/// Handler Best Practices:
-///
-/// -
-///
-/// Handlers should be independent and not rely on side effects from other handlers.
-///
-///
-/// -
-///
-/// Handlers should not throw exceptions for expected failures; use result types or logging instead.
-///
-///
-/// -
-///
-/// Handlers should be idempotent when possible, as the same event may be reprocessed in retry scenarios.
-///
-///
-/// -
-///
-/// For long-running or critical side effects, consider using a persistent outbox pattern
-/// or reliable message queue instead of synchronous event handlers.
-///
-///
-///
-///
-///
-/// Exception Handling:
-/// All exceptions thrown by handlers are logged with handler name and event type. The caller
-/// is responsible for handling appropriately. If you need
-/// to know which handlers failed, inspect the collection.
-///
-///
-public sealed partial class EventBus(
- IServiceProvider serviceProvider,
- ILogger logger,
- BackgroundEventChannel backgroundChannel
-) : IEventBus
-{
- ///
- /// Publishes an event to all registered handlers, ensuring all handlers execute even if some fail.
- ///
- /// The event type, must implement .
- /// The event to publish.
- ///
- /// A cancellation token that propagates cancellation requests to all handlers.
- ///
- ///
- /// A that completes when all handlers have executed.
- ///
- ///
- /// Thrown after all handlers have executed if any handler(s) threw an exception.
- /// The collection contains all exceptions
- /// that were thrown by handlers.
- ///
- ///
- /// Handler failures are isolated by design. If handler A throws, handler B will still execute.
- /// This method will not throw immediately when the first handler fails; instead, it collects
- /// all exceptions and throws them together as an AggregateException. This ensures that:
- ///
- /// -
- /// All handlers have an opportunity to execute their work.
- ///
- /// -
- /// Side effects from successful handlers are preserved.
- ///
- /// -
- /// The caller can see all failures at once, enabling proper diagnostics.
- ///
- ///
- ///
- public async Task PublishAsync(T @event, CancellationToken cancellationToken = default)
- where T : IEvent
- {
- var behaviors = serviceProvider.GetServices>();
-
- // Build the pipeline: behaviors wrap around the handler dispatch
- Func dispatch = () => DispatchToHandlers(@event, cancellationToken);
- foreach (var behavior in behaviors.Reverse())
- {
- var next = dispatch;
- var b = behavior;
- dispatch = () => b.HandleAsync(@event, next, cancellationToken);
- }
-
- await dispatch();
- }
-
- private async Task DispatchToHandlers(T @event, CancellationToken cancellationToken)
- where T : IEvent
- {
- var handlers = serviceProvider.GetServices>();
- List? exceptions = null;
-
- foreach (var handler in handlers)
- {
- try
- {
- await handler.HandleAsync(@event, cancellationToken);
- }
-#pragma warning disable CA1031 // Event bus must isolate handler failures
- catch (Exception ex)
-#pragma warning restore CA1031
- {
- LogHandlerFailed(logger, handler.GetType().Name, typeof(T).Name, ex);
- exceptions ??= [];
- exceptions.Add(ex);
- }
- }
-
- if (exceptions is { Count: > 0 })
- {
- throw new AggregateException(
- $"One or more event handlers for {typeof(T).Name} failed.",
- exceptions
- );
- }
- }
-
- ///
- public void PublishInBackground(T @event)
- where T : IEvent
- {
- backgroundChannel.Enqueue(@event);
- }
-
- [LoggerMessage(
- Level = LogLevel.Error,
- Message = "Event handler {HandlerName} failed for event {EventName}"
- )]
- private static partial void LogHandlerFailed(
- ILogger logger,
- string handlerName,
- string eventName,
- Exception exception
- );
-}
diff --git a/framework/SimpleModule.Core/Events/IEventBus.cs b/framework/SimpleModule.Core/Events/IEventBus.cs
deleted file mode 100644
index 805eb336..00000000
--- a/framework/SimpleModule.Core/Events/IEventBus.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-namespace SimpleModule.Core.Events;
-
-///
-/// Publishes events to registered handlers with exception isolation semantics.
-///
-///
-/// The EventBus contract defines a single method for publishing events. Implementations must ensure
-/// that handler failures are isolated and do not prevent other handlers from executing.
-///
-public interface IEventBus
-{
- ///
- /// Publishes an event to all registered handlers.
- ///
- /// The event type, must implement .
- /// The event to publish.
- ///
- /// A cancellation token that propagates cancellation requests to handlers.
- ///
- ///
- /// A that completes when all handlers have executed.
- ///
- ///
- /// Thrown after all handlers have executed if any handler(s) threw an exception.
- ///
- ///
- /// Handlers are executed sequentially in registration order. Handler failures are isolated
- /// and do not prevent subsequent handlers from executing. If any handlers fail, an
- /// is thrown after all handlers have run.
- ///
- Task PublishAsync(T @event, CancellationToken cancellationToken = default)
- where T : IEvent;
-
- ///
- /// Enqueues an event for background dispatch, returning immediately without waiting for handlers.
- ///
- /// The event type, must implement .
- /// The event to publish in the background.
- ///
- /// The event is dispatched asynchronously by a background service. Handler exceptions are
- /// logged but do not propagate to the caller. Use this for fire-and-forget events where
- /// the caller does not need to know if handlers succeeded (e.g., audit logging, notifications).
- ///
- void PublishInBackground(T @event)
- where T : IEvent;
-}
diff --git a/framework/SimpleModule.Core/Events/IEventHandler.cs b/framework/SimpleModule.Core/Events/IEventHandler.cs
deleted file mode 100644
index 963003bf..00000000
--- a/framework/SimpleModule.Core/Events/IEventHandler.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-namespace SimpleModule.Core.Events;
-
-///
-/// Handles an event of type .
-///
-/// The event type to handle.
-///
-///
-/// Event handlers are invoked by the when a matching event is published.
-/// Multiple handlers can be registered for the same event type, and all will be invoked in
-/// registration order.
-///
-///
-/// Handler Implementation Guidelines:
-///
-/// -
-///
-/// Handlers should be stateless and reusable. Avoid storing mutable state that could be
-/// affected by concurrent or repeated invocations.
-///
-///
-/// -
-///
-/// Handlers should not throw exceptions for expected failures. Use result types, early returns,
-/// or logging instead. Exceptions interrupt the handler chain and must be handled by the caller.
-///
-///
-/// -
-///
-/// Handlers are invoked sequentially in registration order. Do not rely on the execution order
-/// of sibling handlers, but you can rely on handlers registered before yours having already executed.
-///
-///
-/// -
-///
-/// If a handler needs to be transactional or have strong consistency guarantees, consider
-/// registering the event handler in a database transaction or using compensating actions.
-///
-///
-/// -
-///
-/// For long-running operations, consider using a background job queue or service instead of
-/// synchronous event handlers.
-///
-///
-///
-///
-///
-/// Exception Behavior:
-/// If a handler throws an exception, the catches and logs it, then continues
-/// invoking remaining handlers. After all handlers have executed, any collected exceptions are thrown
-/// as an . This design ensures partial success: even if one handler fails,
-/// others complete their work.
-///
-///
-#pragma warning disable CA1711 // Identifiers should not have incorrect suffix - EventHandler is intentional
-public interface IEventHandler
- where T : IEvent
-{
- ///
- /// Handles the specified event.
- ///
- /// The event to handle.
- /// A cancellation token to observe during handler execution.
- /// A representing the asynchronous operation.
- ///
- /// This method is invoked asynchronously by the . If this method throws
- /// an exception, it will be caught, logged, and collected with other exceptions. The event bus
- /// will continue invoking remaining handlers even if this handler fails. After all handlers have
- /// executed, any collected exceptions will be thrown as an .
- ///
- Task HandleAsync(T @event, CancellationToken cancellationToken);
-}
-#pragma warning restore CA1711
diff --git a/framework/SimpleModule.Core/Events/IEventPipelineBehavior.cs b/framework/SimpleModule.Core/Events/IEventPipelineBehavior.cs
deleted file mode 100644
index dd9161f6..00000000
--- a/framework/SimpleModule.Core/Events/IEventPipelineBehavior.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-namespace SimpleModule.Core.Events;
-
-///
-/// Defines a pipeline behavior that wraps event handler execution, enabling
-/// cross-cutting concerns like logging, metrics, retries, or transaction boundaries.
-///
-/// The event type to intercept.
-///
-/// Pipeline behaviors execute in reverse registration order (outermost first),
-/// forming a middleware chain around the event handler invocations.
-/// Register via services.AddScoped<IEventPipelineBehavior<MyEvent>, MyBehavior>().
-///
-///
-///
-/// public sealed class LoggingBehavior<T> : IEventPipelineBehavior<T> where T : IEvent
-/// {
-/// public async Task HandleAsync(T @event, Func<Task> next, CancellationToken ct)
-/// {
-/// _logger.LogInformation("Handling {Event}", typeof(T).Name);
-/// await next();
-/// _logger.LogInformation("Handled {Event}", typeof(T).Name);
-/// }
-/// }
-///
-///
-public interface IEventPipelineBehavior
- where T : IEvent
-{
- Task HandleAsync(T @event, Func next, CancellationToken cancellationToken);
-}
diff --git a/framework/SimpleModule.Core/SimpleModule.Core.csproj b/framework/SimpleModule.Core/SimpleModule.Core.csproj
index 011fc56f..bc6e1d3a 100644
--- a/framework/SimpleModule.Core/SimpleModule.Core.csproj
+++ b/framework/SimpleModule.Core/SimpleModule.Core.csproj
@@ -10,5 +10,6 @@
+
diff --git a/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs b/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs
index a8301a6f..ab98fe3b 100644
--- a/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs
+++ b/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs
@@ -1,26 +1,20 @@
-using System.Collections.Concurrent;
-using System.Reflection;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using SimpleModule.Core.Entities;
using SimpleModule.Core.Events;
+using Wolverine;
namespace SimpleModule.Database.Interceptors;
///
/// Interceptor that collects domain events from entities
-/// before SaveChanges and dispatches them via after a successful save.
-/// Events are cleared from entities after dispatch to prevent re-processing.
+/// before SaveChanges and dispatches them via Wolverine's after
+/// a successful save. Events are cleared from entities after dispatch to prevent re-processing.
/// Registered as scoped — each DbContext gets its own instance, so instance fields are safe.
///
public sealed class DomainEventInterceptor(IServiceProvider serviceProvider)
: SaveChangesInterceptor
{
- private static readonly MethodInfo PublishAsyncMethod = typeof(IEventBus).GetMethod(
- nameof(IEventBus.PublishAsync)
- )!;
- private static readonly ConcurrentDictionary PublishMethodCache = new();
-
private List? _collectedEvents;
public override ValueTask> SavingChangesAsync(
@@ -60,20 +54,15 @@ public override async ValueTask SavedChangesAsync(
if (events is { Count: > 0 })
{
- var eventBus = serviceProvider.GetService();
- if (eventBus is not null)
+ var bus = serviceProvider.GetService();
+ if (bus is not null)
{
foreach (var domainEvent in events)
{
- // Invoke PublishAsync with the concrete event type so that
- // IEventHandler registrations are resolved correctly.
- // A static call to PublishAsync(IEvent) would resolve T as IEvent,
- // missing all concrete handlers.
- var concreteMethod = PublishMethodCache.GetOrAdd(
- domainEvent.GetType(),
- static type => PublishAsyncMethod.MakeGenericMethod(type)
- );
- await (Task)concreteMethod.Invoke(eventBus, [domainEvent, cancellationToken])!;
+ // Wolverine's PublishAsync dispatches by the runtime type of the
+ // message via its non-generic overload internally, so passing the
+ // boxed IEvent resolves the correct handler chain.
+ await bus.PublishAsync(domainEvent);
}
}
}
diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Dependency.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Dependency.cs
index e58f1070..7823bbec 100644
--- a/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Dependency.cs
+++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Dependency.cs
@@ -7,7 +7,7 @@ internal static partial class DiagnosticDescriptors
internal static readonly DiagnosticDescriptor CircularModuleDependency = new(
id: "SM0010",
title: "Circular module dependency detected",
- messageFormat: "Circular module dependency detected. Cycle: {0}. {1}To break this cycle, identify which direction is the primary dependency and reverse the other using IEventBus. For example, if {2} is the primary consumer of {3}: (1) Keep {2} \u2192 {3}.Contracts. (2) Remove {3} \u2192 {2}.Contracts. (3) In {3}, publish events via IEventBus instead of calling {2} directly. (4) In {2}, implement IEventHandler to handle those events. Learn more: https://docs.simplemodule.dev/module-dependencies.",
+ messageFormat: "Circular module dependency detected. Cycle: {0}. {1}To break this cycle, identify which direction is the primary dependency and reverse the other using IMessageBus. For example, if {2} is the primary consumer of {3}: (1) Keep {2} \u2192 {3}.Contracts. (2) Remove {3} \u2192 {2}.Contracts. (3) In {3}, publish events via IMessageBus instead of calling {2} directly. (4) In {2}, add a Wolverine message handler to react to those events. Learn more: https://docs.simplemodule.dev/module-dependencies.",
category: "SimpleModule.Generator",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true
diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
index 4ddb2968..b88f63f4 100644
--- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
+++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
@@ -9,7 +9,6 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using SimpleModule.Core.Constants;
-using SimpleModule.Core.Events;
using SimpleModule.Core.Exceptions;
using SimpleModule.Core.Health;
using SimpleModule.Core.Inertia;
@@ -23,6 +22,7 @@
using SimpleModule.Hosting.Inertia;
using SimpleModule.Hosting.Middleware;
using SimpleModule.Hosting.RateLimiting;
+using Wolverine;
using ZiggyCreatures.Caching.Fusion;
namespace SimpleModule.Hosting;
@@ -73,13 +73,13 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(
.Services.AddFusionCache()
.WithDefaultEntryOptions(o => o.Duration = TimeSpan.FromMinutes(5));
- builder.Services.AddSingleton();
- builder.Services.AddHostedService();
- builder.Services.AddScoped();
- // Lazy lets services break factory-lambda cycles
- // (e.g. SettingsService ↔ AuditingEventBus via ISettingsContracts).
- builder.Services.AddScoped(sp => new Lazy(() =>
- sp.GetRequiredService()
+ // Wolverine: in-process messaging only. Handlers are auto-discovered
+ // from loaded assemblies. No external transports, no message persistence.
+ builder.Host.UseWolverine(_ => { });
+ // Lazy lets services break factory-lambda cycles
+ // (e.g. SettingsService ↔ AuditingMessageBus via ISettingsContracts).
+ builder.Services.AddScoped(sp => new Lazy(() =>
+ sp.GetRequiredService()
));
builder.Services.AddScoped();
diff --git a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs
index b8e47e2e..f1e62e62 100644
--- a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs
+++ b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs
@@ -3,8 +3,8 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
-using SimpleModule.Core.Events;
using SimpleModule.Database.Interceptors;
+using Wolverine;
using ZiggyCreatures.Caching.Fusion;
namespace SimpleModule.Hosting;
@@ -34,13 +34,14 @@ public static HostApplicationBuilder AddSimpleModuleWorker(this HostApplicationB
builder
.Services.AddFusionCache()
.WithDefaultEntryOptions(o => o.Duration = TimeSpan.FromMinutes(5));
- builder.Services.AddSingleton();
- builder.Services.AddHostedService();
- builder.Services.AddScoped();
- // Lazy lets services break factory-lambda cycles
- // (e.g. SettingsService ↔ AuditingEventBus via ISettingsContracts).
- builder.Services.AddScoped(sp => new Lazy(() =>
- sp.GetRequiredService()
+
+ // Wolverine: in-process messaging only. Handlers are auto-discovered
+ // from loaded assemblies. No external transports, no message persistence.
+ builder.UseWolverine(_ => { });
+ // Lazy lets services break factory-lambda cycles
+ // (e.g. SettingsService ↔ AuditingMessageBus via ISettingsContracts).
+ builder.Services.AddScoped(sp => new Lazy(() =>
+ sp.GetRequiredService()
));
// HttpContextAccessor is required by EntityInterceptor even in a worker
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs
index d6ed7eee..5ed0f08e 100644
--- a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs
+++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs
@@ -8,10 +8,10 @@
using SimpleModule.AuditLogs.Pipeline;
using SimpleModule.AuditLogs.Retention;
using SimpleModule.Core;
-using SimpleModule.Core.Events;
using SimpleModule.Core.Settings;
using SimpleModule.Database;
using SimpleModule.Settings.Contracts;
+using Wolverine;
namespace SimpleModule.AuditLogs;
@@ -34,15 +34,10 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
services.AddHostedService();
services.AddHostedService();
- // Decorate IEventBus with auditing
- services.AddScoped(sp =>
- {
- var innerBus = ActivatorUtilities.CreateInstance(sp);
- var auditCtx = sp.GetRequiredService();
- var auditChan = sp.GetRequiredService();
- var settingsContracts = sp.GetService();
- return new AuditingEventBus(innerBus, auditCtx, auditChan, settingsContracts);
- });
+ // Decorate Wolverine's IMessageBus so domain events published via Wolverine
+ // are captured in the audit log. Uses Scrutor because Wolverine owns the
+ // base IMessageBus registration.
+ services.Decorate();
}
public void ConfigureMiddleware(IApplicationBuilder app)
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditEntryExtractor.cs
similarity index 52%
rename from modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs
rename to modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditEntryExtractor.cs
index d58cc7c6..4e461bf3 100644
--- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs
+++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditEntryExtractor.cs
@@ -1,73 +1,27 @@
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
-using Microsoft.Extensions.Logging;
using SimpleModule.AuditLogs.Contracts;
using SimpleModule.Core.Events;
-using SimpleModule.Core.Settings;
-using SimpleModule.Settings.Contracts;
namespace SimpleModule.AuditLogs.Pipeline;
-public sealed partial class AuditingEventBus(
- IEventBus inner,
- IAuditContext auditContext,
- AuditChannel channel,
- ISettingsContracts? settings = null,
- ILogger? logger = null
-) : IEventBus
+///
+/// Reflects over an instance to produce a matching
+/// . Used by
+/// to build audit entries for every published domain event.
+///
+internal static partial class AuditEntryExtractor
{
[GeneratedRegex(
@"^(?.+?)(?Created|Updated|Deleted|Viewed|Exported|LoginSuccess|LoginFailed|PermissionGranted|PermissionRevoked|SettingChanged)Event$"
)]
private static partial Regex EventNamePattern();
- public async Task PublishAsync(T @event, CancellationToken cancellationToken = default)
- where T : IEvent
+ public static AuditEntry Extract(IEvent evt, IAuditContext auditContext)
{
- // Publish to inner event bus FIRST, before attempting audit
- await inner.PublishAsync(@event, cancellationToken);
-
- // Only audit on successful publish
- var enabled =
- settings is null
- || await settings.GetSettingAsync("auditlogs.capture.domain", SettingScope.System)
- != false;
-
- if (enabled)
- {
- try
- {
- var entry = ExtractAuditEntry(@event);
- channel.Enqueue(entry);
- }
- catch (OperationCanceledException)
- {
- // Don't log cancellation, just propagate
- throw;
- }
-#pragma warning disable CA1031
- catch (Exception ex)
- {
- // Audit failures must never break primary operations
- // We catch all exceptions because any failure during audit (channel, extraction, etc.)
- // should be logged but not propagated to the caller
- logger?.LogError(ex, "Failed to enqueue audit entry; audit will not be recorded");
- }
-#pragma warning restore CA1031
- }
- }
-
- public void PublishInBackground(T @event)
- where T : IEvent
- {
- inner.PublishInBackground(@event);
- }
-
- private AuditEntry ExtractAuditEntry(T @event)
- where T : IEvent
- {
- var typeName = typeof(T).Name;
+ var eventType = evt.GetType();
+ var typeName = eventType.Name;
var match = EventNamePattern().Match(typeName);
string? module = null;
@@ -86,10 +40,10 @@ private AuditEntry ExtractAuditEntry(T @event)
}
}
- var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
+ var properties = eventType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var prop in properties)
{
- var value = prop.GetValue(@event);
+ var value = prop.GetValue(evt);
if (prop.Name.EndsWith("Id", StringComparison.Ordinal) && value is not null)
{
entityId ??= value.ToString();
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingMessageBus.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingMessageBus.cs
new file mode 100644
index 00000000..1c489c2e
--- /dev/null
+++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingMessageBus.cs
@@ -0,0 +1,163 @@
+using Microsoft.Extensions.Logging;
+using SimpleModule.AuditLogs.Contracts;
+using SimpleModule.Core.Events;
+using SimpleModule.Core.Settings;
+using SimpleModule.Settings.Contracts;
+using Wolverine;
+
+namespace SimpleModule.AuditLogs.Pipeline;
+
+///
+/// Decorator over Wolverine's that captures an audit
+/// entry every time an is published or invoked. Audit
+/// failures are logged but never propagate — auditing must not break primary
+/// operations.
+///
+public sealed class AuditingMessageBus(
+ IMessageBus inner,
+ IAuditContext auditContext,
+ AuditChannel channel,
+ ISettingsContracts? settings = null,
+ ILogger? logger = null
+) : IMessageBus
+{
+ public string? TenantId
+ {
+ get => inner.TenantId;
+ set => inner.TenantId = value;
+ }
+
+ public async ValueTask PublishAsync(T message, DeliveryOptions? options = null)
+ {
+ await inner.PublishAsync(message, options);
+ if (message is IEvent evt)
+ {
+ await AuditAsync(evt);
+ }
+ }
+
+ public async ValueTask SendAsync(T message, DeliveryOptions? options = null)
+ {
+ await inner.SendAsync(message, options);
+ if (message is IEvent evt)
+ {
+ await AuditAsync(evt);
+ }
+ }
+
+ public async Task InvokeAsync(
+ object message,
+ CancellationToken cancellation = default,
+ TimeSpan? timeout = default
+ )
+ {
+ await inner.InvokeAsync(message, cancellation, timeout);
+ if (message is IEvent evt)
+ {
+ await AuditAsync(evt);
+ }
+ }
+
+ public async Task InvokeAsync(
+ object message,
+ DeliveryOptions options,
+ CancellationToken cancellation = default,
+ TimeSpan? timeout = default
+ )
+ {
+ await inner.InvokeAsync(message, options, cancellation, timeout);
+ if (message is IEvent evt)
+ {
+ await AuditAsync(evt);
+ }
+ }
+
+ public async Task InvokeAsync(
+ object message,
+ CancellationToken cancellation = default,
+ TimeSpan? timeout = default
+ )
+ {
+ var result = await inner.InvokeAsync(message, cancellation, timeout);
+ if (message is IEvent evt)
+ {
+ await AuditAsync(evt);
+ }
+ return result;
+ }
+
+ public async Task InvokeAsync(
+ object message,
+ DeliveryOptions options,
+ CancellationToken cancellation = default,
+ TimeSpan? timeout = default
+ )
+ {
+ var result = await inner.InvokeAsync(message, options, cancellation, timeout);
+ if (message is IEvent evt)
+ {
+ await AuditAsync(evt);
+ }
+ return result;
+ }
+
+ public Task InvokeForTenantAsync(
+ string tenantId,
+ object message,
+ CancellationToken cancellation = default,
+ TimeSpan? timeout = default
+ ) => inner.InvokeForTenantAsync(tenantId, message, cancellation, timeout);
+
+ public Task InvokeForTenantAsync(
+ string tenantId,
+ object message,
+ CancellationToken cancellation = default,
+ TimeSpan? timeout = default
+ ) => inner.InvokeForTenantAsync(tenantId, message, cancellation, timeout);
+
+ public IDestinationEndpoint EndpointFor(string endpointName) => inner.EndpointFor(endpointName);
+
+ public IDestinationEndpoint EndpointFor(Uri uri) => inner.EndpointFor(uri);
+
+ public IReadOnlyList PreviewSubscriptions(object message) =>
+ inner.PreviewSubscriptions(message);
+
+ public IReadOnlyList PreviewSubscriptions(object message, DeliveryOptions options) =>
+ inner.PreviewSubscriptions(message, options);
+
+ public ValueTask BroadcastToTopicAsync(
+ string topicName,
+ object message,
+ DeliveryOptions? options = null
+ ) => inner.BroadcastToTopicAsync(topicName, message, options);
+
+ private async Task AuditAsync(IEvent evt)
+ {
+ var enabled =
+ settings is null
+ || await settings.GetSettingAsync("auditlogs.capture.domain", SettingScope.System)
+ != false;
+
+ if (!enabled)
+ {
+ return;
+ }
+
+ try
+ {
+ var entry = AuditEntryExtractor.Extract(evt, auditContext);
+ channel.Enqueue(entry);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+#pragma warning disable CA1031
+ catch (Exception ex)
+ {
+ // Audit failures must never break primary operations.
+ logger?.LogError(ex, "Failed to enqueue audit entry; audit will not be recorded");
+ }
+#pragma warning restore CA1031
+ }
+}
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/SimpleModule.AuditLogs.csproj b/modules/AuditLogs/src/SimpleModule.AuditLogs/SimpleModule.AuditLogs.csproj
index 6205824f..ce851d3b 100644
--- a/modules/AuditLogs/src/SimpleModule.AuditLogs/SimpleModule.AuditLogs.csproj
+++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/SimpleModule.AuditLogs.csproj
@@ -9,6 +9,7 @@
+
diff --git a/modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditingEventBusTests.cs b/modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditingEventBusTests.cs
deleted file mode 100644
index a07ad617..00000000
--- a/modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditingEventBusTests.cs
+++ /dev/null
@@ -1,164 +0,0 @@
-using System.Threading.Channels;
-using FluentAssertions;
-using Microsoft.Extensions.Logging;
-using NSubstitute;
-using SimpleModule.AuditLogs;
-using SimpleModule.AuditLogs.Contracts;
-using SimpleModule.AuditLogs.Pipeline;
-using SimpleModule.Core.Events;
-using SimpleModule.Core.Settings;
-using SimpleModule.Settings.Contracts;
-
-namespace AuditLogs.Tests.Unit;
-
-public record ProductCreatedEvent(int ProductId, string Name) : IEvent;
-
-public record OrderDeletedEvent(int OrderId) : IEvent;
-
-public record SimpleEvent(string Data) : IEvent;
-
-public class AuditingEventBusTests
-{
- private readonly IEventBus _innerBus = Substitute.For();
- private readonly AuditChannel _channel = new();
- private readonly AuditContext _auditContext;
- private readonly AuditingEventBus _sut;
-
- public AuditingEventBusTests()
- {
- _auditContext = new AuditContext
- {
- UserId = "test-user",
- UserName = "Test User",
- IpAddress = "127.0.0.1",
- };
- _sut = new AuditingEventBus(_innerBus, _auditContext, _channel);
- }
-
- [Fact]
- public async Task PublishAsync_ExtractsModuleAndAction_FromEventName()
- {
- var @event = new ProductCreatedEvent(1, "Widget");
-
- await _sut.PublishAsync(@event);
-
- _channel.Reader.TryRead(out var entry).Should().BeTrue();
- entry!.Module.Should().Be("Product");
- entry.Action.Should().Be(AuditAction.Created);
- }
-
- [Fact]
- public async Task PublishAsync_ExtractsEntityId_FromProperties()
- {
- var @event = new ProductCreatedEvent(42, "Widget");
-
- await _sut.PublishAsync(@event);
-
- _channel.Reader.TryRead(out var entry).Should().BeTrue();
- entry!.EntityId.Should().Be("42");
- }
-
- [Fact]
- public async Task PublishAsync_DelegatesToInnerEventBus()
- {
- var @event = new ProductCreatedEvent(1, "Widget");
-
- await _sut.PublishAsync(@event);
-
- await _innerBus.Received(1).PublishAsync(@event, Arg.Any());
- }
-
- [Fact]
- public async Task PublishAsync_EnqueuesAuditEntryToChannel()
- {
- var @event = new OrderDeletedEvent(7);
-
- await _sut.PublishAsync(@event);
-
- _channel.Reader.TryRead(out var entry).Should().BeTrue();
- entry.Should().NotBeNull();
- entry!.Source.Should().Be(AuditSource.Domain);
- entry.Action.Should().Be(AuditAction.Deleted);
- entry.EntityType.Should().Be("Order");
- }
-
- [Fact]
- public async Task PublishAsync_SetsAuditContextFields()
- {
- var @event = new SimpleEvent("test");
-
- await _sut.PublishAsync(@event);
-
- _channel.Reader.TryRead(out var entry).Should().BeTrue();
- entry!.UserId.Should().Be("test-user");
- entry.UserName.Should().Be("Test User");
- entry.IpAddress.Should().Be("127.0.0.1");
- entry.CorrelationId.Should().Be(_auditContext.CorrelationId);
- }
-
- [Fact]
- public async Task PublishAsync_DoesNotEnqueueAuditEntry_WhenInnerPublishFails()
- {
- var @event = new ProductCreatedEvent(1, "Widget");
- var testException = new InvalidOperationException("Inner bus failed");
-
- // Configure the mock to throw when PublishAsync is called
- var calls = 0;
- _innerBus
- .PublishAsync(Arg.Any(), Arg.Any())
- .ReturnsForAnyArgs(x =>
- {
- calls++;
- return Task.FromException(testException);
- });
-
- // Act & Assert: Should throw because inner bus failed
- var action = () => _sut.PublishAsync(@event);
- await action.Should().ThrowAsync();
-
- // Verify inner bus was called
- calls.Should().Be(1);
-
- // Assert: No audit entry should be queued
- _channel
- .Reader.TryRead(out _)
- .Should()
- .BeFalse("Audit entry should not be queued when publish fails");
- }
-
- [Fact]
- public async Task PublishAsync_DelegatesToInnerEventBus_BeforeEnqueuingAudit()
- {
- var @event = new ProductCreatedEvent(1, "Widget");
- var publishAsyncCalled = false;
-
- _innerBus
- .PublishAsync(Arg.Any(), Arg.Any())
- .Returns(x =>
- {
- publishAsyncCalled = true;
- return Task.CompletedTask;
- });
-
- await _sut.PublishAsync(@event);
-
- // Verify that PublishAsync is called (audit happens after)
- publishAsyncCalled.Should().BeTrue();
- }
-
- [Fact]
- public async Task PublishAsync_DoesNotThrow_WhenAuditEnqueueFails()
- {
- var @event = new ProductCreatedEvent(1, "Widget");
- var busLogger = Substitute.For>();
-
- // Create a null logger for channel (will not throw, just silently log)
- var closedChannel = new AuditChannel(null);
-
- var sut = new AuditingEventBus(_innerBus, _auditContext, closedChannel, null, busLogger);
-
- // This should not throw despite potential audit issues
- var action = () => sut.PublishAsync(@event);
- await action.Should().NotThrowAsync();
- }
-}
diff --git a/modules/Datasets/src/SimpleModule.Datasets/Jobs/ConvertDatasetJob.cs b/modules/Datasets/src/SimpleModule.Datasets/Jobs/ConvertDatasetJob.cs
index 8e65f343..dd3acc30 100644
--- a/modules/Datasets/src/SimpleModule.Datasets/Jobs/ConvertDatasetJob.cs
+++ b/modules/Datasets/src/SimpleModule.Datasets/Jobs/ConvertDatasetJob.cs
@@ -2,11 +2,11 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SimpleModule.BackgroundJobs.Contracts;
-using SimpleModule.Core.Events;
using SimpleModule.Datasets.Contracts;
using SimpleModule.Datasets.Contracts.Events;
using SimpleModule.Datasets.Converters;
using SimpleModule.Storage;
+using Wolverine;
namespace SimpleModule.Datasets.Jobs;
@@ -14,7 +14,7 @@ public sealed partial class ConvertDatasetJob(
DatasetsDbContext db,
IStorageProvider storage,
DatasetConverterRegistry converters,
- IEventBus eventBus,
+ IMessageBus bus,
ILogger logger
) : IModuleJob
{
@@ -102,10 +102,7 @@ await storage.GetAsync(sourcePath, cancellationToken)
await db.SaveChangesAsync(cancellationToken);
LogDerivativeCreated(logger, payload.DatasetId, target);
- await eventBus.PublishAsync(
- new DatasetDerivativeCreated(datasetId, target),
- cancellationToken
- );
+ await bus.PublishAsync(new DatasetDerivativeCreated(datasetId, target));
}
#pragma warning disable CA1031
catch (Exception ex)
diff --git a/modules/Datasets/src/SimpleModule.Datasets/Jobs/ProcessDatasetJob.cs b/modules/Datasets/src/SimpleModule.Datasets/Jobs/ProcessDatasetJob.cs
index 3940e4bf..aaf8df66 100644
--- a/modules/Datasets/src/SimpleModule.Datasets/Jobs/ProcessDatasetJob.cs
+++ b/modules/Datasets/src/SimpleModule.Datasets/Jobs/ProcessDatasetJob.cs
@@ -3,13 +3,13 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SimpleModule.BackgroundJobs.Contracts;
-using SimpleModule.Core.Events;
using SimpleModule.Core.Settings;
using SimpleModule.Datasets.Contracts;
using SimpleModule.Datasets.Contracts.Events;
using SimpleModule.Datasets.Processing;
using SimpleModule.Settings.Contracts;
using SimpleModule.Storage;
+using Wolverine;
namespace SimpleModule.Datasets.Jobs;
@@ -17,7 +17,7 @@ public sealed partial class ProcessDatasetJob(
DatasetsDbContext db,
IStorageProvider storage,
DatasetProcessorRegistry processors,
- IEventBus eventBus,
+ IMessageBus bus,
IBackgroundJobs jobs,
ISettingsContracts settings,
ILogger logger
@@ -124,10 +124,7 @@ await jobs.EnqueueAsync(
LogDatasetFailed(logger, payload.DatasetId, ex);
}
- await eventBus.PublishAsync(
- new DatasetProcessed(datasetId, finalStatus),
- cancellationToken
- );
+ await bus.PublishAsync(new DatasetProcessed(datasetId, finalStatus));
}
[LoggerMessage(Level = LogLevel.Warning, Message = "Dataset {Id} not found for processing")]
diff --git a/modules/Email/src/SimpleModule.Email/EmailService.Templates.cs b/modules/Email/src/SimpleModule.Email/EmailService.Templates.cs
index 6c88c2ec..91b2ad14 100644
--- a/modules/Email/src/SimpleModule.Email/EmailService.Templates.cs
+++ b/modules/Email/src/SimpleModule.Email/EmailService.Templates.cs
@@ -68,7 +68,7 @@ public async Task CreateTemplateAsync(CreateEmailTemplateRequest
await db.SaveChangesAsync();
LogTemplateCreated(logger, template.Id, template.Name);
- eventBus.PublishInBackground(
+ await bus.PublishAsync(
new EmailTemplateCreatedEvent(template.Id, template.Name, template.Slug)
);
@@ -105,7 +105,7 @@ await db.EmailTemplates.FindAsync(id)
await db.SaveChangesAsync();
LogTemplateUpdated(logger, template.Id, template.Name);
- eventBus.PublishInBackground(
+ await bus.PublishAsync(
new EmailTemplateUpdatedEvent(template.Id, template.Name, changedFields)
);
@@ -123,7 +123,7 @@ await db.EmailTemplates.FindAsync(id)
await db.SaveChangesAsync();
LogTemplateDeleted(logger, id);
- eventBus.PublishInBackground(new EmailTemplateDeletedEvent(id, templateName));
+ await bus.PublishAsync(new EmailTemplateDeletedEvent(id, templateName));
}
[LoggerMessage(
diff --git a/modules/Email/src/SimpleModule.Email/EmailService.cs b/modules/Email/src/SimpleModule.Email/EmailService.cs
index ff221966..bc26a258 100644
--- a/modules/Email/src/SimpleModule.Email/EmailService.cs
+++ b/modules/Email/src/SimpleModule.Email/EmailService.cs
@@ -2,19 +2,19 @@
using Microsoft.Extensions.Logging;
using SimpleModule.BackgroundJobs.Contracts;
using SimpleModule.Core;
-using SimpleModule.Core.Events;
using SimpleModule.Email.Contracts;
using SimpleModule.Email.Contracts.Events;
using SimpleModule.Email.Jobs;
using SimpleModule.Email.Providers;
using SimpleModule.Email.Services;
+using Wolverine;
namespace SimpleModule.Email;
public partial class EmailService(
EmailDbContext db,
IEmailProvider emailProvider,
- IEventBus eventBus,
+ IMessageBus bus,
IBackgroundJobs backgroundJobs,
ILogger logger
) : IEmailContracts
diff --git a/modules/Email/src/SimpleModule.Email/Jobs/RetryFailedEmailsJob.cs b/modules/Email/src/SimpleModule.Email/Jobs/RetryFailedEmailsJob.cs
index e76808ab..05c13b25 100644
--- a/modules/Email/src/SimpleModule.Email/Jobs/RetryFailedEmailsJob.cs
+++ b/modules/Email/src/SimpleModule.Email/Jobs/RetryFailedEmailsJob.cs
@@ -2,9 +2,9 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SimpleModule.BackgroundJobs.Contracts;
-using SimpleModule.Core.Events;
using SimpleModule.Email.Contracts;
using SimpleModule.Email.Contracts.Events;
+using Wolverine;
namespace SimpleModule.Email.Jobs;
@@ -12,7 +12,7 @@ public partial class RetryFailedEmailsJob(
EmailDbContext db,
IBackgroundJobs backgroundJobs,
IOptions options,
- IEventBus eventBus,
+ IMessageBus bus,
ILogger logger
) : IModuleJob
{
@@ -44,7 +44,7 @@ CancellationToken cancellationToken
foreach (var message in failedMessages)
{
LogRetryAttempt(logger, message.Id, message.To, message.RetryCount);
- eventBus.PublishInBackground(
+ await bus.PublishAsync(
new EmailRetryAttemptEvent(message.Id, message.To, message.RetryCount)
);
diff --git a/modules/Email/src/SimpleModule.Email/Jobs/SendEmailJob.cs b/modules/Email/src/SimpleModule.Email/Jobs/SendEmailJob.cs
index fe5e89e7..254f35be 100644
--- a/modules/Email/src/SimpleModule.Email/Jobs/SendEmailJob.cs
+++ b/modules/Email/src/SimpleModule.Email/Jobs/SendEmailJob.cs
@@ -1,10 +1,10 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SimpleModule.BackgroundJobs.Contracts;
-using SimpleModule.Core.Events;
using SimpleModule.Email.Contracts;
using SimpleModule.Email.Contracts.Events;
using SimpleModule.Email.Providers;
+using Wolverine;
namespace SimpleModule.Email.Jobs;
@@ -12,7 +12,7 @@ public partial class SendEmailJob(
EmailDbContext db,
IEmailProvider emailProvider,
IOptions options,
- IEventBus eventBus,
+ IMessageBus bus,
ILogger logger
) : IModuleJob
{
@@ -51,9 +51,7 @@ CancellationToken cancellationToken
await db.SaveChangesAsync(cancellationToken);
LogEmailSent(logger, message.Id, message.To);
- eventBus.PublishInBackground(
- new EmailSentEvent(message.Id, message.To, message.Subject)
- );
+ await bus.PublishAsync(new EmailSentEvent(message.Id, message.To, message.Subject));
}
catch (Exception ex)
when (ex
@@ -70,7 +68,7 @@ or MailKit.Security.SslHandshakeException
await db.SaveChangesAsync(cancellationToken);
LogEmailFailed(logger, message.Id, message.To, ex);
- eventBus.PublishInBackground(
+ await bus.PublishAsync(
new EmailFailedEvent(message.Id, message.To, message.Subject, ex.Message)
);
}
diff --git a/modules/Email/tests/SimpleModule.Email.Tests/Unit/EmailServiceTests.cs b/modules/Email/tests/SimpleModule.Email.Tests/Unit/EmailServiceTests.cs
index 86d9a877..4024e045 100644
--- a/modules/Email/tests/SimpleModule.Email.Tests/Unit/EmailServiceTests.cs
+++ b/modules/Email/tests/SimpleModule.Email.Tests/Unit/EmailServiceTests.cs
@@ -1,10 +1,11 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using NSubstitute;
using SimpleModule.BackgroundJobs.Contracts;
-using SimpleModule.Core.Events;
using SimpleModule.Database;
using SimpleModule.Email.Providers;
+using Wolverine;
namespace SimpleModule.Email.Tests.Unit;
@@ -12,7 +13,7 @@ public sealed partial class EmailServiceTests : IDisposable
{
private readonly EmailDbContext _db;
private readonly EmailService _sut;
- private readonly TestEventBus _eventBus = new();
+ private readonly IMessageBus _bus = Substitute.For();
private readonly TestBackgroundJobs _backgroundJobs = new();
public EmailServiceTests()
@@ -38,7 +39,7 @@ public EmailServiceTests()
_sut = new EmailService(
_db,
provider,
- _eventBus,
+ _bus,
_backgroundJobs,
NullLogger.Instance
);
@@ -83,22 +84,4 @@ public Task ToggleRecurringAsync(RecurringJobId id, CancellationToken ct =
public Task GetStatusAsync(JobId jobId, CancellationToken ct = default) =>
Task.FromResult(null);
}
-
- private sealed class TestEventBus : IEventBus
- {
- public List PublishedEvents { get; } = [];
-
- public Task PublishAsync(T @event, CancellationToken cancellationToken = default)
- where T : IEvent
- {
- PublishedEvents.Add(@event);
- return Task.CompletedTask;
- }
-
- public void PublishInBackground(T @event)
- where T : IEvent
- {
- PublishedEvents.Add(@event);
- }
- }
}
diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/SetOverrideEndpoint.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/SetOverrideEndpoint.cs
index 6cd3e423..c3cba7bb 100644
--- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/SetOverrideEndpoint.cs
+++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/SetOverrideEndpoint.cs
@@ -3,9 +3,9 @@
using Microsoft.AspNetCore.Routing;
using SimpleModule.Core;
using SimpleModule.Core.Authorization;
-using SimpleModule.Core.Events;
using SimpleModule.FeatureFlags.Contracts;
using SimpleModule.FeatureFlags.Contracts.Events;
+using Wolverine;
namespace SimpleModule.FeatureFlags.Endpoints.FeatureFlags;
@@ -21,11 +21,11 @@ public void Map(IEndpointRouteBuilder app) =>
string name,
SetOverrideRequest request,
IFeatureFlagContracts featureFlags,
- IEventBus eventBus
+ IMessageBus bus
) =>
{
var result = await featureFlags.SetOverrideAsync(name, request);
- await eventBus.PublishAsync(
+ await bus.PublishAsync(
new FeatureFlagOverrideChangedEvent(
name,
OverrideAction.Set,
diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/UpdateEndpoint.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/UpdateEndpoint.cs
index 9c54d125..752aaab6 100644
--- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/UpdateEndpoint.cs
+++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/Endpoints/FeatureFlags/UpdateEndpoint.cs
@@ -4,9 +4,9 @@
using Microsoft.AspNetCore.Routing;
using SimpleModule.Core;
using SimpleModule.Core.Authorization;
-using SimpleModule.Core.Events;
using SimpleModule.FeatureFlags.Contracts;
using SimpleModule.FeatureFlags.Contracts.Events;
+using Wolverine;
namespace SimpleModule.FeatureFlags.Endpoints.FeatureFlags;
@@ -22,13 +22,13 @@ public void Map(IEndpointRouteBuilder app) =>
string name,
UpdateFeatureFlagRequest request,
IFeatureFlagContracts featureFlags,
- IEventBus eventBus,
+ IMessageBus bus,
ClaimsPrincipal user
) =>
{
var flag = await featureFlags.UpdateFlagAsync(name, request);
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown";
- await eventBus.PublishAsync(
+ await bus.PublishAsync(
new FeatureFlagToggledEvent(name, request.IsEnabled, userId)
);
return TypedResults.Ok(flag);
diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs
index f74ef249..28ebff02 100644
--- a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs
+++ b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs
@@ -1,16 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-using SimpleModule.Core.Events;
using SimpleModule.FileStorage.Contracts;
using SimpleModule.FileStorage.Contracts.Events;
using SimpleModule.Storage;
+using Wolverine;
namespace SimpleModule.FileStorage;
public sealed partial class FileStorageService(
FileStorageDbContext db,
IStorageProvider storageProvider,
- IEventBus eventBus,
+ IMessageBus bus,
ILogger logger
) : IFileStorageContracts
{
@@ -81,7 +81,7 @@ public async Task UploadFileAsync(
LogFileUploaded(logger, storedFile.Id, storedFile.FileName);
- await eventBus.PublishAsync(
+ await bus.PublishAsync(
new FileUploadedEvent(
storedFile.Id,
storedFile.FileName,
@@ -129,7 +129,7 @@ public async Task DeleteFileAsync(StoredFile file)
LogFileDeleted(logger, file.Id, file.FileName);
- eventBus.PublishInBackground(new FileDeletedEvent(file.Id, file.FileName));
+ await bus.PublishAsync(new FileDeletedEvent(file.Id, file.FileName));
}
public async Task DownloadFileAsync(FileStorageId id)
diff --git a/modules/FileStorage/tests/SimpleModule.FileStorage.Tests/FileStorageServiceTests.cs b/modules/FileStorage/tests/SimpleModule.FileStorage.Tests/FileStorageServiceTests.cs
index 16aba7c5..96ca0575 100644
--- a/modules/FileStorage/tests/SimpleModule.FileStorage.Tests/FileStorageServiceTests.cs
+++ b/modules/FileStorage/tests/SimpleModule.FileStorage.Tests/FileStorageServiceTests.cs
@@ -38,7 +38,7 @@ public FileStorageServiceTests()
_service = new FileStorageService(
_db,
_storageProvider,
- new TestEventBus(),
+ new TestMessageBus(),
NullLogger.Instance
);
}
@@ -229,7 +229,7 @@ public async Task UploadFileAsync_Cleans_Up_Storage_On_DB_Failure()
var failingService = new FileStorageService(
_db,
failingProvider,
- new TestEventBus(),
+ new TestMessageBus(),
NullLogger.Instance
);
diff --git a/modules/Orders/src/SimpleModule.Orders/OrderService.cs b/modules/Orders/src/SimpleModule.Orders/OrderService.cs
index a3ed1ee8..67dca206 100644
--- a/modules/Orders/src/SimpleModule.Orders/OrderService.cs
+++ b/modules/Orders/src/SimpleModule.Orders/OrderService.cs
@@ -1,11 +1,11 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-using SimpleModule.Core.Events;
using SimpleModule.Core.Exceptions;
using SimpleModule.Orders.Contracts;
using SimpleModule.Orders.Contracts.Events;
using SimpleModule.Products.Contracts;
using SimpleModule.Users.Contracts;
+using Wolverine;
namespace SimpleModule.Orders;
@@ -13,7 +13,7 @@ public sealed partial class OrderService(
OrdersDbContext db,
IUserContracts users,
IProductContracts products,
- IEventBus eventBus,
+ IMessageBus bus,
ILogger logger
) : IOrderContracts
{
@@ -70,7 +70,7 @@ public async Task CreateOrderAsync(CreateOrderRequest request)
LogOrderCreated(logger, order.Id, order.UserId, order.Total);
- await eventBus.PublishAsync(new OrderCreatedEvent(order.Id, order.UserId, order.Total));
+ await bus.PublishAsync(new OrderCreatedEvent(order.Id, order.UserId, order.Total));
return order;
}
diff --git a/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/OrderServiceTests.cs b/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/OrderServiceTests.cs
index e709fedb..07a0183a 100644
--- a/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/OrderServiceTests.cs
+++ b/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/OrderServiceTests.cs
@@ -3,13 +3,13 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
-using SimpleModule.Core.Events;
using SimpleModule.Core.Exceptions;
using SimpleModule.Database;
using SimpleModule.Orders;
using SimpleModule.Orders.Contracts;
using SimpleModule.Products.Contracts;
using SimpleModule.Users.Contracts;
+using Wolverine;
namespace Orders.Tests.Unit;
@@ -18,7 +18,7 @@ public sealed class OrderServiceTests : IDisposable
private readonly OrdersDbContext _db;
private readonly IUserContracts _users = Substitute.For();
private readonly IProductContracts _products = Substitute.For();
- private readonly IEventBus _eventBus = Substitute.For();
+ private readonly IMessageBus _bus = Substitute.For();
private readonly OrderService _sut;
public OrderServiceTests()
@@ -38,13 +38,7 @@ public OrderServiceTests()
_db = new OrdersDbContext(options, dbOptions);
_db.Database.OpenConnection();
_db.Database.EnsureCreated();
- _sut = new OrderService(
- _db,
- _users,
- _products,
- _eventBus,
- NullLogger.Instance
- );
+ _sut = new OrderService(_db, _users, _products, _bus, NullLogger.Instance);
}
public void Dispose() => _db.Dispose();
diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderService.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderService.cs
index b336720b..9aaafae6 100644
--- a/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderService.cs
+++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderService.cs
@@ -1,16 +1,16 @@
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-using SimpleModule.Core.Events;
using SimpleModule.Core.Exceptions;
using SimpleModule.PageBuilder.Contracts;
using SimpleModule.PageBuilder.Contracts.Events;
+using Wolverine;
namespace SimpleModule.PageBuilder;
public sealed partial class PageBuilderService(
PageBuilderDbContext db,
- IEventBus eventBus,
+ IMessageBus bus,
ILogger logger
) : IPageBuilderContracts, IPageBuilderTemplateContracts, IPageBuilderTagContracts
{
@@ -98,7 +98,7 @@ public async Task CreatePageAsync(CreatePageRequest request)
LogPageCreated(logger, page.Id, page.Title);
- await eventBus.PublishAsync(new PageCreatedEvent(page.Id, page.Title, page.Slug));
+ await bus.PublishAsync(new PageCreatedEvent(page.Id, page.Title, page.Slug));
return page;
}
@@ -156,7 +156,7 @@ public async Task DeletePageAsync(PageId id)
LogPageDeleted(logger, id);
- eventBus.PublishInBackground(new PageDeletedEvent(id));
+ await bus.PublishAsync(new PageDeletedEvent(id));
}
public async Task PublishPageAsync(PageId id)
@@ -175,7 +175,7 @@ public async Task PublishPageAsync(PageId id)
LogPagePublished(logger, page.Id, page.Title);
- await eventBus.PublishAsync(new PagePublishedEvent(page.Id, page.Title));
+ await bus.PublishAsync(new PagePublishedEvent(page.Id, page.Title));
return page;
}
@@ -190,7 +190,7 @@ public async Task UnpublishPageAsync(PageId id)
LogPageUnpublished(logger, page.Id, page.Title);
- await eventBus.PublishAsync(new PageUnpublishedEvent(page.Id, page.Title));
+ await bus.PublishAsync(new PageUnpublishedEvent(page.Id, page.Title));
return page;
}
diff --git a/modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageBuilderServiceTests.Helpers.cs b/modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageBuilderServiceTests.Helpers.cs
index 3cd4f0a1..b6a6c07d 100644
--- a/modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageBuilderServiceTests.Helpers.cs
+++ b/modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageBuilderServiceTests.Helpers.cs
@@ -31,7 +31,7 @@ public PageBuilderServiceTests()
_db.Database.EnsureCreated();
_sut = new PageBuilderService(
_db,
- new TestEventBus(),
+ new TestMessageBus(),
NullLogger.Instance
);
}
diff --git a/modules/Products/src/SimpleModule.Products/ProductService.cs b/modules/Products/src/SimpleModule.Products/ProductService.cs
index ee199551..34f7455a 100644
--- a/modules/Products/src/SimpleModule.Products/ProductService.cs
+++ b/modules/Products/src/SimpleModule.Products/ProductService.cs
@@ -1,14 +1,14 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-using SimpleModule.Core.Events;
using SimpleModule.Products.Contracts;
using SimpleModule.Products.Contracts.Events;
+using Wolverine;
namespace SimpleModule.Products;
public partial class ProductService(
ProductsDbContext db,
- IEventBus eventBus,
+ IMessageBus bus,
ILogger logger
) : IProductContracts
{
@@ -41,9 +41,7 @@ public async Task CreateProductAsync(CreateProductRequest request)
LogProductCreated(logger, product.Id, product.Name);
- await eventBus.PublishAsync(
- new ProductCreatedEvent(product.Id, product.Name, product.Price)
- );
+ await bus.PublishAsync(new ProductCreatedEvent(product.Id, product.Name, product.Price));
return product;
}
@@ -63,9 +61,7 @@ public async Task UpdateProductAsync(ProductId id, UpdateProductRequest
LogProductUpdated(logger, product.Id, product.Name);
- await eventBus.PublishAsync(
- new ProductUpdatedEvent(product.Id, product.Name, product.Price)
- );
+ await bus.PublishAsync(new ProductUpdatedEvent(product.Id, product.Name, product.Price));
return product;
}
@@ -83,7 +79,7 @@ public async Task DeleteProductAsync(ProductId id)
LogProductDeleted(logger, id);
- eventBus.PublishInBackground(new ProductDeletedEvent(id));
+ await bus.PublishAsync(new ProductDeletedEvent(id));
}
[LoggerMessage(Level = LogLevel.Warning, Message = "Product with ID {ProductId} not found")]
diff --git a/modules/Products/tests/SimpleModule.Products.Tests/Unit/ProductServiceTests.cs b/modules/Products/tests/SimpleModule.Products.Tests/Unit/ProductServiceTests.cs
index 729279cd..bf26faac 100644
--- a/modules/Products/tests/SimpleModule.Products.Tests/Unit/ProductServiceTests.cs
+++ b/modules/Products/tests/SimpleModule.Products.Tests/Unit/ProductServiceTests.cs
@@ -32,7 +32,7 @@ public ProductServiceTests()
_db = new ProductsDbContext(options, dbOptions);
_db.Database.OpenConnection();
_db.Database.EnsureCreated();
- _sut = new ProductService(_db, new TestEventBus(), NullLogger.Instance);
+ _sut = new ProductService(_db, new TestMessageBus(), NullLogger.Instance);
}
public void Dispose() => _db.Dispose();
diff --git a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs
index d8d68224..6ab10c86 100644
--- a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs
+++ b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs
@@ -2,10 +2,10 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-using SimpleModule.Core.Events;
using SimpleModule.Core.Settings;
using SimpleModule.Settings.Contracts;
using SimpleModule.Settings.Contracts.Events;
+using Wolverine;
using ZiggyCreatures.Caching.Fusion;
namespace SimpleModule.Settings;
@@ -14,7 +14,7 @@ public sealed partial class SettingsService(
SettingsDbContext db,
ISettingsDefinitionRegistry definitions,
IFusionCache cache,
- Lazy eventBus,
+ Lazy bus,
IOptions moduleOptions,
ILogger logger
) : ISettingsContracts
@@ -118,9 +118,9 @@ public async Task SetSettingAsync(
await cache.RemoveAsync(BuildCacheKey(key, scope, userId));
LogSettingUpdated(key, scope);
- // IEventBus is Lazy to break the SettingsService → IEventBus → AuditingEventBus
+ // IMessageBus is Lazy to break the SettingsService → IMessageBus → AuditingMessageBus
// → ISettingsContracts → SettingsService cycle at construction time.
- await eventBus.Value.PublishAsync(new SettingChangedEvent(key, oldValue, value, scope));
+ await bus.Value.PublishAsync(new SettingChangedEvent(key, oldValue, value, scope));
}
public async Task DeleteSettingAsync(string key, SettingScope scope, string? userId = null)
@@ -138,7 +138,7 @@ public async Task DeleteSettingAsync(string key, SettingScope scope, string? use
await cache.RemoveAsync(BuildCacheKey(key, scope, userId));
LogSettingDeleted(key, scope);
- eventBus.Value.PublishInBackground(new SettingDeletedEvent(key, scope));
+ await bus.Value.PublishAsync(new SettingDeletedEvent(key, scope));
}
}
diff --git a/modules/Settings/tests/SimpleModule.Settings.Tests/SimpleModule.Settings.Tests.csproj b/modules/Settings/tests/SimpleModule.Settings.Tests/SimpleModule.Settings.Tests.csproj
index 6b41b1c7..f22ba4c5 100644
--- a/modules/Settings/tests/SimpleModule.Settings.Tests/SimpleModule.Settings.Tests.csproj
+++ b/modules/Settings/tests/SimpleModule.Settings.Tests/SimpleModule.Settings.Tests.csproj
@@ -14,6 +14,7 @@
+
diff --git a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs
index 154eac86..afb63cf9 100644
--- a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs
+++ b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs
@@ -2,11 +2,12 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
-using SimpleModule.Core.Events;
+using NSubstitute;
using SimpleModule.Core.Settings;
using SimpleModule.Database;
using SimpleModule.Settings;
using SimpleModule.Tests.Shared.Fakes;
+using Wolverine;
using ZiggyCreatures.Caching.Fusion;
namespace Settings.Tests.Unit;
@@ -45,7 +46,7 @@ public SettingsServiceTests()
_db,
registry,
_cache,
- new Lazy(() => new TestEventBus()),
+ new Lazy(() => Substitute.For()),
Options.Create(new SettingsModuleOptions()),
NullLogger.Instance
);
diff --git a/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs b/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs
index 25923f85..6be6bf93 100644
--- a/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs
+++ b/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs
@@ -1,15 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-using SimpleModule.Core.Events;
using SimpleModule.Core.Exceptions;
using SimpleModule.Tenants.Contracts;
using SimpleModule.Tenants.Contracts.Events;
+using Wolverine;
namespace SimpleModule.Tenants;
public sealed partial class TenantService(
TenantsDbContext db,
- IEventBus eventBus,
+ IMessageBus bus,
ILogger logger
) : ITenantContracts
{
@@ -82,7 +82,7 @@ public async Task CreateTenantAsync(CreateTenantRequest request)
await db.SaveChangesAsync();
LogTenantCreated(logger, entity.Id, entity.Name);
- await eventBus.PublishAsync(new TenantCreatedEvent(entity.Id, entity.Name, entity.Slug));
+ await bus.PublishAsync(new TenantCreatedEvent(entity.Id, entity.Name, entity.Slug));
return MapToDto(entity);
}
@@ -104,7 +104,7 @@ public async Task UpdateTenantAsync(TenantId id, UpdateTenantRequest req
await db.SaveChangesAsync();
LogTenantUpdated(logger, entity.Id, entity.Name);
- await eventBus.PublishAsync(new TenantUpdatedEvent(entity.Id, entity.Name));
+ await bus.PublishAsync(new TenantUpdatedEvent(entity.Id, entity.Name));
return (await GetTenantByIdAsync(id))!;
}
@@ -136,7 +136,7 @@ public async Task ChangeStatusAsync(TenantId id, TenantStatus status)
await db.SaveChangesAsync();
LogTenantStatusChanged(logger, id, oldStatus, status);
- await eventBus.PublishAsync(new TenantStatusChangedEvent(id, oldStatus, status));
+ await bus.PublishAsync(new TenantStatusChangedEvent(id, oldStatus, status));
return (await GetTenantByIdAsync(id))!;
}
@@ -155,7 +155,7 @@ public async Task AddHostAsync(TenantId tenantId, AddTenantHostReque
await db.SaveChangesAsync();
LogHostAdded(logger, tenantId, request.HostName);
- await eventBus.PublishAsync(new TenantHostAddedEvent(tenantId, request.HostName));
+ await bus.PublishAsync(new TenantHostAddedEvent(tenantId, request.HostName));
return MapHostToDto(hostEntity);
}
@@ -175,7 +175,7 @@ public async Task RemoveHostAsync(TenantId tenantId, TenantHostId hostId)
await db.SaveChangesAsync();
LogHostRemoved(logger, tenantId, hostName);
- await eventBus.PublishAsync(new TenantHostRemovedEvent(tenantId, hostName));
+ await bus.PublishAsync(new TenantHostRemovedEvent(tenantId, hostName));
}
private static Tenant MapToDto(TenantEntity entity) =>
diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/SimpleModule.Tenants.Tests.csproj b/modules/Tenants/tests/SimpleModule.Tenants.Tests/SimpleModule.Tenants.Tests.csproj
index 12ab737c..7c1fff97 100644
--- a/modules/Tenants/tests/SimpleModule.Tenants.Tests/SimpleModule.Tenants.Tests.csproj
+++ b/modules/Tenants/tests/SimpleModule.Tenants.Tests/SimpleModule.Tenants.Tests.csproj
@@ -13,6 +13,7 @@
+
diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs
index 1d2b00f4..dac46f48 100644
--- a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs
+++ b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs
@@ -2,11 +2,12 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
-using SimpleModule.Core.Events;
+using NSubstitute;
using SimpleModule.Core.Exceptions;
using SimpleModule.Database;
using SimpleModule.Tenants;
using SimpleModule.Tenants.Contracts;
+using Wolverine;
namespace Tenants.Tests.Unit;
@@ -14,7 +15,7 @@ public sealed class TenantServiceTests : IDisposable
{
private readonly TenantsDbContext _db;
private readonly TenantService _sut;
- private readonly TestEventBus _eventBus = new();
+ private readonly IMessageBus _bus = Substitute.For();
public TenantServiceTests()
{
@@ -33,7 +34,7 @@ public TenantServiceTests()
_db = new TenantsDbContext(options, dbOptions);
_db.Database.OpenConnection();
_db.Database.EnsureCreated();
- _sut = new TenantService(_db, _eventBus, NullLogger.Instance);
+ _sut = new TenantService(_db, _bus, NullLogger.Instance);
}
public void Dispose() => _db.Dispose();
@@ -85,9 +86,11 @@ public async Task CreateTenantAsync_CreatesAndReturnsTenant()
tenant.Status.Should().Be(TenantStatus.Active);
tenant.Hosts.Should().HaveCount(1);
tenant.Hosts[0].HostName.Should().Be("new.localhost");
- _eventBus
- .PublishedEvents.Should()
- .ContainSingle(e => e is SimpleModule.Tenants.Contracts.Events.TenantCreatedEvent);
+ await _bus.Received(1)
+ .PublishAsync(
+ Arg.Any(),
+ Arg.Any()
+ );
}
[Fact]
@@ -97,9 +100,11 @@ public async Task UpdateTenantAsync_WithValidData_UpdatesTenant()
var updated = await _sut.UpdateTenantAsync(TenantId.From(1), request);
updated.Name.Should().Be("Updated Acme");
- _eventBus
- .PublishedEvents.Should()
- .Contain(e => e is SimpleModule.Tenants.Contracts.Events.TenantUpdatedEvent);
+ await _bus.Received()
+ .PublishAsync(
+ Arg.Any(),
+ Arg.Any()
+ );
}
[Fact]
@@ -129,9 +134,11 @@ public async Task ChangeStatusAsync_ChangesStatusAndPublishesEvent()
var result = await _sut.ChangeStatusAsync(TenantId.From(1), TenantStatus.Suspended);
result.Status.Should().Be(TenantStatus.Suspended);
- _eventBus
- .PublishedEvents.Should()
- .Contain(e => e is SimpleModule.Tenants.Contracts.Events.TenantStatusChangedEvent);
+ await _bus.Received()
+ .PublishAsync(
+ Arg.Any(),
+ Arg.Any()
+ );
}
[Fact]
@@ -179,22 +186,4 @@ public async Task GetTenantByHostNameAsync_ReturnsNullForUnknownHost()
tenant.Should().BeNull();
}
-
- private sealed class TestEventBus : IEventBus
- {
- public List PublishedEvents { get; } = [];
-
- public Task PublishAsync(T @event, CancellationToken cancellationToken = default)
- where T : IEvent
- {
- PublishedEvents.Add(@event);
- return Task.CompletedTask;
- }
-
- public void PublishInBackground(T @event)
- where T : IEvent
- {
- PublishedEvents.Add(@event);
- }
- }
}
diff --git a/modules/Users/src/SimpleModule.Users/UserAdminService.cs b/modules/Users/src/SimpleModule.Users/UserAdminService.cs
index 3877ad8a..79e8e8a1 100644
--- a/modules/Users/src/SimpleModule.Users/UserAdminService.cs
+++ b/modules/Users/src/SimpleModule.Users/UserAdminService.cs
@@ -1,17 +1,17 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SimpleModule.Core;
-using SimpleModule.Core.Events;
using SimpleModule.Core.Exceptions;
using SimpleModule.Users.Contracts;
using SimpleModule.Users.Contracts.Events;
+using Wolverine;
namespace SimpleModule.Users;
public sealed class UserAdminService(
UserManager userManager,
UsersDbContext db,
- IEventBus eventBus
+ IMessageBus bus
) : IUserAdminContracts
{
public async Task> GetUsersPagedAsync(
@@ -141,7 +141,7 @@ public async Task CreateUserWithPasswordAsync(CreateAdminUserReque
var roles = await userManager.GetRolesAsync(user);
- await eventBus.PublishAsync(
+ await bus.PublishAsync(
new UserCreatedEvent(UserId.From(user.Id), user.Email ?? string.Empty, user.DisplayName)
);
@@ -160,7 +160,7 @@ public async Task UpdateUserDetailsAsync(UserId id, UpdateAdminUserRequest reque
await userManager.UpdateAsync(user);
- await eventBus.PublishAsync(
+ await bus.PublishAsync(
new UserUpdatedEvent(UserId.From(user.Id), user.Email ?? string.Empty, user.DisplayName)
);
}
@@ -186,7 +186,7 @@ public async Task SetUserRolesAsync(UserId id, IEnumerable roles)
await userManager.AddToRolesAsync(user, toAdd);
}
- await eventBus.PublishAsync(new UserRolesChangedEvent(id, newRoles.ToList()));
+ await bus.PublishAsync(new UserRolesChangedEvent(id, newRoles.ToList()));
}
public async Task ResetPasswordAsync(UserId id, string newPassword)
diff --git a/modules/Users/src/SimpleModule.Users/UserService.cs b/modules/Users/src/SimpleModule.Users/UserService.cs
index 080d44b0..3eeab6a0 100644
--- a/modules/Users/src/SimpleModule.Users/UserService.cs
+++ b/modules/Users/src/SimpleModule.Users/UserService.cs
@@ -1,16 +1,16 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-using SimpleModule.Core.Events;
using SimpleModule.Users.Contracts;
using SimpleModule.Users.Contracts.Events;
+using Wolverine;
namespace SimpleModule.Users;
public partial class UserService(
UserManager userManager,
RoleManager roleManager,
- IEventBus eventBus,
+ IMessageBus bus,
ILogger logger
) : IUserContracts
{
@@ -65,7 +65,7 @@ public async Task CreateUserAsync(CreateUserRequest request)
LogUserCreated(logger, user.Id, user.Email);
- await eventBus.PublishAsync(
+ await bus.PublishAsync(
new UserCreatedEvent(UserId.From(user.Id), user.Email ?? string.Empty, user.DisplayName)
);
@@ -88,7 +88,7 @@ public async Task UpdateUserAsync(UserId id, UpdateUserRequest request)
LogUserUpdated(logger, user.Id);
- await eventBus.PublishAsync(
+ await bus.PublishAsync(
new UserUpdatedEvent(UserId.From(user.Id), user.Email ?? string.Empty, user.DisplayName)
);
@@ -107,7 +107,7 @@ public async Task DeleteUserAsync(UserId id)
LogUserDeleted(logger, id);
- eventBus.PublishInBackground(new UserDeletedEvent(id));
+ await bus.PublishAsync(new UserDeletedEvent(id));
}
public async Task> GetRoleIdsByNamesAsync(
diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserServiceTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserServiceTests.cs
index c79fa997..6b970015 100644
--- a/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserServiceTests.cs
+++ b/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserServiceTests.cs
@@ -37,7 +37,7 @@ public UserServiceTests()
_sut = new UserService(
_userManager,
_roleManager,
- new TestEventBus(),
+ new TestMessageBus(),
NullLogger.Instance
);
}
diff --git a/tests/SimpleModule.Core.Tests/EventBusTests.cs b/tests/SimpleModule.Core.Tests/EventBusTests.cs
deleted file mode 100644
index f3ddacd0..00000000
--- a/tests/SimpleModule.Core.Tests/EventBusTests.cs
+++ /dev/null
@@ -1,215 +0,0 @@
-using FluentAssertions;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using SimpleModule.Core.Events;
-
-namespace SimpleModule.Core.Tests;
-
-public sealed class EventBusTests
-{
- private sealed record TestEvent(string Value) : IEvent;
-
- private sealed class TestEventHandler : IEventHandler
- {
- public List ReceivedEvents { get; } = [];
-
- public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken)
- {
- ReceivedEvents.Add(@event);
- return Task.CompletedTask;
- }
- }
-
- private sealed class ThrowingEventHandler : IEventHandler
- {
- public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken) =>
- throw new InvalidOperationException("Handler failed");
- }
-
- private sealed class OrderTrackingHandler : IEventHandler
- {
- public List CallOrder { get; }
- public int Id { get; }
-
- public OrderTrackingHandler(List callOrder, int id)
- {
- CallOrder = callOrder;
- Id = id;
- }
-
- public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken)
- {
- CallOrder.Add(Id);
- return Task.CompletedTask;
- }
- }
-
- [Fact]
- public async Task PublishAsync_WithRegisteredHandler_InvokesHandler()
- {
- var handler = new TestEventHandler();
- var services = new ServiceCollection();
- services.AddSingleton>(handler);
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- await bus.PublishAsync(new TestEvent("test"));
-
- handler.ReceivedEvents.Should().ContainSingle().Which.Value.Should().Be("test");
- }
-
- [Fact]
- public async Task PublishAsync_WithMultipleHandlers_InvokesAll()
- {
- var handler1 = new TestEventHandler();
- var handler2 = new TestEventHandler();
- var services = new ServiceCollection();
- services.AddSingleton>(handler1);
- services.AddSingleton>(handler2);
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- await bus.PublishAsync(new TestEvent("test"));
-
- handler1.ReceivedEvents.Should().ContainSingle();
- handler2.ReceivedEvents.Should().ContainSingle();
- }
-
- [Fact]
- public async Task PublishAsync_WithNoHandlers_DoesNotThrow()
- {
- var services = new ServiceCollection();
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- var act = () => bus.PublishAsync(new TestEvent("test"));
-
- await act.Should().NotThrowAsync();
- }
-
- [Fact]
- public async Task PublishAsync_WhenHandlerThrows_OtherHandlersStillExecute()
- {
- var successHandler = new TestEventHandler();
- var services = new ServiceCollection();
- services.AddSingleton>(new ThrowingEventHandler());
- services.AddSingleton>(successHandler);
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- var act = () => bus.PublishAsync(new TestEvent("test"));
-
- await act.Should().ThrowAsync();
- // The second handler should still have been called
- successHandler.ReceivedEvents.Should().ContainSingle();
- }
-
- [Fact]
- public async Task PublishAsync_WhenHandlerThrows_ThrowsAggregateException()
- {
- var services = new ServiceCollection();
- services.AddSingleton>(new ThrowingEventHandler());
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- var act = () => bus.PublishAsync(new TestEvent("test"));
-
- var ex = await act.Should().ThrowAsync();
- ex.Which.InnerExceptions.Should().ContainSingle();
- ex.Which.InnerExceptions[0].Should().BeOfType();
- }
-
- [Fact]
- public async Task PublishAsync_WhenMultipleHandlersThrow_AggregatesAllExceptions()
- {
- var services = new ServiceCollection();
- services.AddSingleton>(new ThrowingEventHandler());
- services.AddSingleton>(new ThrowingEventHandler());
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- var act = () => bus.PublishAsync(new TestEvent("test"));
-
- var ex = await act.Should().ThrowAsync();
- ex.Which.InnerExceptions.Should().HaveCount(2);
- }
-
- [Fact]
- public async Task PublishAsync_HandlersExecuteInRegistrationOrder()
- {
- var callOrder = new List();
- var services = new ServiceCollection();
- services.AddSingleton>(new OrderTrackingHandler(callOrder, 1));
- services.AddSingleton>(new OrderTrackingHandler(callOrder, 2));
- services.AddSingleton>(new OrderTrackingHandler(callOrder, 3));
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- await bus.PublishAsync(new TestEvent("test"));
-
- callOrder.Should().ContainInOrder(1, 2, 3);
- }
-
- [Fact]
- public async Task PublishAsync_PassesCancellationToken_ToHandlers()
- {
- CancellationToken receivedToken = default;
- var services = new ServiceCollection();
- services.AddSingleton>(
- new DelegateHandler(
- (_, ct) =>
- {
- receivedToken = ct;
- return Task.CompletedTask;
- }
- )
- );
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
- using var cts = new CancellationTokenSource();
-
- await bus.PublishAsync(new TestEvent("test"), cts.Token);
-
- receivedToken.Should().Be(cts.Token);
- }
-
- private sealed class DelegateHandler(Func handler)
- : IEventHandler
- {
- public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken) =>
- handler(@event, cancellationToken);
- }
-}
diff --git a/tests/SimpleModule.Core.Tests/Events/EventBusIntegrationTests.cs b/tests/SimpleModule.Core.Tests/Events/EventBusIntegrationTests.cs
deleted file mode 100644
index 0eb97c6f..00000000
--- a/tests/SimpleModule.Core.Tests/Events/EventBusIntegrationTests.cs
+++ /dev/null
@@ -1,244 +0,0 @@
-using FluentAssertions;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging.Abstractions;
-using SimpleModule.Core.Events;
-
-namespace SimpleModule.Core.Tests.Events;
-
-///
-/// Integration tests verifying cross-module event flows work correctly using the
-/// full DI container and event bus pipeline (handlers, pipeline behaviors, background
-/// dispatch).
-///
-public sealed class EventBusIntegrationTests
-{
- private sealed record TestEvent(string Value) : IEvent;
-
- // Distinct marker types let DI register three independent handler instances
- // without copy-pasting three identical handler classes.
- private interface IHandlerSlot
- {
- List ReceivedEvents { get; }
- }
-
- private sealed class SlotOne : IHandlerSlot
- {
- public List ReceivedEvents { get; } = [];
- }
-
- private sealed class SlotTwo : IHandlerSlot
- {
- public List ReceivedEvents { get; } = [];
- }
-
- private sealed class SlotThree : IHandlerSlot
- {
- public List ReceivedEvents { get; } = [];
- }
-
- private sealed class TrackingHandler(TSlot slot) : IEventHandler
- where TSlot : IHandlerSlot
- {
- public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken)
- {
- slot.ReceivedEvents.Add(@event);
- return Task.CompletedTask;
- }
- }
-
- private sealed class ThrowingHandler : IEventHandler
- {
- public bool WasCalled { get; private set; }
-
- public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken)
- {
- WasCalled = true;
- throw new InvalidOperationException("Handler intentionally failed");
- }
- }
-
- private sealed class SignallingHandler(TaskCompletionSource tcs)
- : IEventHandler
- {
- public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken)
- {
- tcs.TrySetResult(@event);
- return Task.CompletedTask;
- }
- }
-
- private sealed class TrackingPipelineBehavior : IEventPipelineBehavior
- {
- public bool BeforeHandlerCalled { get; private set; }
- public bool AfterHandlerCalled { get; private set; }
- public TestEvent? ReceivedEvent { get; private set; }
-
- public async Task HandleAsync(
- TestEvent @event,
- Func next,
- CancellationToken cancellationToken
- )
- {
- ReceivedEvent = @event;
- BeforeHandlerCalled = true;
- await next();
- AfterHandlerCalled = true;
- }
- }
-
- private static ServiceProvider BuildProvider(Action configure)
- {
- var services = new ServiceCollection();
-
- services.AddSingleton(_ => new BackgroundEventChannel(
- NullLogger.Instance
- ));
- services.AddSingleton(sp => new EventBus(
- sp,
- NullLogger.Instance,
- sp.GetRequiredService()
- ));
- services.AddSingleton(sp => new BackgroundEventDispatcher(
- sp.GetRequiredService(),
- sp.GetRequiredService(),
- NullLogger.Instance
- ));
-
- configure(services);
-
- return services.BuildServiceProvider();
- }
-
- [Fact]
- public async Task Event_PublishAsync_InvokesRegisteredHandler()
- {
- var slot = new SlotOne();
- await using var provider = BuildProvider(services =>
- {
- services.AddSingleton(slot);
- services.AddSingleton, TrackingHandler>();
- });
- var bus = provider.GetRequiredService();
-
- await bus.PublishAsync(new TestEvent("integration-test"));
-
- slot.ReceivedEvents.Should().ContainSingle().Which.Value.Should().Be("integration-test");
- }
-
- [Fact]
- public async Task Event_PublishAsync_MultipleHandlers_AllInvoked()
- {
- var slot1 = new SlotOne();
- var slot2 = new SlotTwo();
- var slot3 = new SlotThree();
- await using var provider = BuildProvider(services =>
- {
- services.AddSingleton(slot1);
- services.AddSingleton(slot2);
- services.AddSingleton(slot3);
- services.AddSingleton, TrackingHandler>();
- services.AddSingleton, TrackingHandler>();
- services.AddSingleton, TrackingHandler>();
- });
- var bus = provider.GetRequiredService();
-
- await bus.PublishAsync(new TestEvent("multi-handler"));
-
- slot1.ReceivedEvents.Should().ContainSingle().Which.Value.Should().Be("multi-handler");
- slot2.ReceivedEvents.Should().ContainSingle().Which.Value.Should().Be("multi-handler");
- slot3.ReceivedEvents.Should().ContainSingle().Which.Value.Should().Be("multi-handler");
- }
-
- [Fact]
- public async Task Event_PublishAsync_HandlerThrows_OtherHandlersStillRun()
- {
- var slotBefore = new SlotOne();
- var slotAfter = new SlotTwo();
- var throwingHandler = new ThrowingHandler();
-
- await using var provider = BuildProvider(services =>
- {
- services.AddSingleton(slotBefore);
- services.AddSingleton(slotAfter);
- services.AddSingleton, TrackingHandler>();
- services.AddSingleton>(throwingHandler);
- services.AddSingleton, TrackingHandler>();
- });
- var bus = provider.GetRequiredService();
-
- var act = () => bus.PublishAsync(new TestEvent("partial-failure"));
-
- var ex = await act.Should().ThrowAsync();
- ex.Which.InnerExceptions.Should()
- .ContainSingle()
- .Which.Should()
- .BeOfType()
- .Which.Message.Should()
- .Be("Handler intentionally failed");
-
- slotBefore
- .ReceivedEvents.Should()
- .ContainSingle()
- .Which.Value.Should()
- .Be("partial-failure");
- throwingHandler.WasCalled.Should().BeTrue();
- slotAfter
- .ReceivedEvents.Should()
- .ContainSingle()
- .Which.Value.Should()
- .Be("partial-failure");
- }
-
- [Fact]
- public async Task Event_PublishInBackground_EventuallyInvokesHandler()
- {
- var tcs = new TaskCompletionSource(
- TaskCreationOptions.RunContinuationsAsynchronously
- );
- await using var provider = BuildProvider(services =>
- {
- services.AddSingleton(tcs);
- services.AddSingleton, SignallingHandler>();
- });
- var bus = provider.GetRequiredService();
- var dispatcher = provider.GetRequiredService();
- using var cts = new CancellationTokenSource();
- await dispatcher.StartAsync(cts.Token);
-
- try
- {
- bus.PublishInBackground(new TestEvent("background-event"));
-
- var received = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
- received.Value.Should().Be("background-event");
- }
- finally
- {
- await cts.CancelAsync();
- await dispatcher.StopAsync(CancellationToken.None);
- }
- }
-
- [Fact]
- public async Task Event_PipelineBehavior_WrapsHandlerExecution()
- {
- var behavior = new TrackingPipelineBehavior();
- var slot = new SlotOne();
-
- await using var provider = BuildProvider(services =>
- {
- services.AddSingleton>(behavior);
- services.AddSingleton(slot);
- services.AddSingleton, TrackingHandler>();
- });
- var bus = provider.GetRequiredService();
-
- await bus.PublishAsync(new TestEvent("pipeline-test"));
-
- behavior.BeforeHandlerCalled.Should().BeTrue();
- behavior.AfterHandlerCalled.Should().BeTrue();
- behavior.ReceivedEvent.Should().NotBeNull();
- behavior.ReceivedEvent!.Value.Should().Be("pipeline-test");
- slot.ReceivedEvents.Should().ContainSingle().Which.Value.Should().Be("pipeline-test");
- }
-}
diff --git a/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.Cancellation.cs b/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.Cancellation.cs
deleted file mode 100644
index 073349a6..00000000
--- a/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.Cancellation.cs
+++ /dev/null
@@ -1,128 +0,0 @@
-using FluentAssertions;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging.Abstractions;
-using SimpleModule.Core.Events;
-
-namespace SimpleModule.Core.Tests.Events;
-
-public sealed partial class EventBusPartialFailureTests
-{
- ///
- /// Test: CancellationToken is propagated to all handlers.
- /// This ensures handlers can respond to cancellation requests.
- ///
- [Fact]
- public async Task PublishAsync_CancellationToken_IsPropagatedToAllHandlers()
- {
- var receivedTokens = new List();
-
- var handler1 = new DelegateHandler(
- (_, ct) =>
- {
- receivedTokens.Add(ct);
- return Task.CompletedTask;
- }
- );
- var handler2 = new DelegateHandler(
- (_, ct) =>
- {
- receivedTokens.Add(ct);
- return Task.CompletedTask;
- }
- );
-
- var services = new ServiceCollection();
- services.AddSingleton>(handler1);
- services.AddSingleton>(handler2);
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- using var cts = new CancellationTokenSource();
- await bus.PublishAsync(new TestEvent("test"), cts.Token);
-
- receivedTokens.Should().HaveCount(2);
- receivedTokens.Should().AllSatisfy(t => t.Should().Be(cts.Token));
- }
-
- ///
- /// Test: CancellationToken is propagated even when handlers fail.
- ///
- [Fact]
- public async Task PublishAsync_CancellationToken_PropagatedEvenWhenHandlersFail()
- {
- var receivedToken = default(CancellationToken);
- var handler = new DelegateHandler(
- (_, ct) =>
- {
- receivedToken = ct;
- return Task.CompletedTask;
- }
- );
-
- var services = new ServiceCollection();
- services.AddSingleton>(handler);
- services.AddSingleton>(new FailingHandler());
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- using var cts = new CancellationTokenSource();
-
- var act = async () => await bus.PublishAsync(new TestEvent("test"), cts.Token);
- await act.Should().ThrowAsync();
-
- receivedToken.Should().Be(cts.Token);
- }
-
- ///
- /// Test: When no handlers fail, no exception is thrown.
- ///
- [Fact]
- public async Task PublishAsync_WhenAllHandlersSucceed_DoesNotThrow()
- {
- var handler1 = new SuccessfulHandler();
- var handler2 = new SuccessfulHandler();
-
- var services = new ServiceCollection();
- services.AddSingleton>(handler1);
- services.AddSingleton>(handler2);
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- var act = async () => await bus.PublishAsync(new TestEvent("test"));
-
- await act.Should().NotThrowAsync();
- handler1.Executed.Should().BeTrue();
- handler2.Executed.Should().BeTrue();
- }
-
- ///
- /// Test: When no handlers are registered, no exception is thrown.
- ///
- [Fact]
- public async Task PublishAsync_WhenNoHandlersRegistered_DoesNotThrow()
- {
- var services = new ServiceCollection();
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- var act = async () => await bus.PublishAsync(new TestEvent("test"));
-
- await act.Should().NotThrowAsync();
- }
-}
diff --git a/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.Helpers.cs b/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.Helpers.cs
deleted file mode 100644
index 13e7da55..00000000
--- a/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.Helpers.cs
+++ /dev/null
@@ -1,80 +0,0 @@
-using SimpleModule.Core.Events;
-
-namespace SimpleModule.Core.Tests.Events;
-
-public sealed partial class EventBusPartialFailureTests
-{
- private sealed record TestEvent(string Value) : IEvent;
-
- ///
- /// Test handler that tracks successful execution.
- ///
- private sealed class SuccessfulHandler : IEventHandler
- {
- public bool Executed { get; private set; }
- public TestEvent? ReceivedEvent { get; private set; }
-
- public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken)
- {
- ReceivedEvent = @event;
- Executed = true;
- return Task.CompletedTask;
- }
- }
-
- ///
- /// Test handler that throws a specific exception.
- ///
- private sealed class FailingHandler : IEventHandler
- {
- private readonly Exception _exception;
-
- public FailingHandler(Exception? exception = null)
- {
- _exception = exception ?? new InvalidOperationException("Handler failed");
- }
-
- public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken)
- {
- throw _exception;
- }
- }
-
- ///
- /// Test handler that tracks execution order using a shared list.
- ///
- private sealed class OrderTrackingHandler : IEventHandler
- {
- private readonly List _executionOrder;
- private readonly string _handlerId;
- private readonly Exception? _exception;
-
- public OrderTrackingHandler(
- List executionOrder,
- string handlerId,
- Exception? exception = null
- )
- {
- _executionOrder = executionOrder;
- _handlerId = handlerId;
- _exception = exception;
- }
-
- public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken)
- {
- _executionOrder.Add(_handlerId);
- return _exception != null ? Task.FromException(_exception) : Task.CompletedTask;
- }
- }
-
- ///
- /// Helper handler that delegates to a provided function.
- /// Used for testing token and event propagation.
- ///
- private sealed class DelegateHandler(Func handler)
- : IEventHandler
- {
- public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken) =>
- handler(@event, cancellationToken);
- }
-}
diff --git a/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.cs b/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.cs
deleted file mode 100644
index 65404b87..00000000
--- a/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.cs
+++ /dev/null
@@ -1,208 +0,0 @@
-using FluentAssertions;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging.Abstractions;
-using SimpleModule.Core.Events;
-
-namespace SimpleModule.Core.Tests.Events;
-
-///
-/// Comprehensive tests for EventBus exception isolation and partial failure handling.
-/// These tests verify that the EventBus correctly implements the partial success semantics:
-/// all handlers execute even if some fail, and failures are collected and rethrown together.
-///
-public sealed partial class EventBusPartialFailureTests
-{
- ///
- /// Test: When one handler throws, other handlers still execute.
- /// This is the core partial failure guarantee.
- ///
- [Fact]
- public async Task PublishAsync_WhenOneHandlerFails_OtherHandlersStillExecute()
- {
- var successHandler1 = new SuccessfulHandler();
- var failingHandler = new FailingHandler(new InvalidOperationException("Handler 2 failed"));
- var successHandler2 = new SuccessfulHandler();
-
- var services = new ServiceCollection();
- services.AddSingleton>(successHandler1);
- services.AddSingleton>(failingHandler);
- services.AddSingleton>(successHandler2);
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- var testEvent = new TestEvent("test-value");
- var act = async () => await bus.PublishAsync(testEvent);
-
- // AggregateException should be thrown
- var ex = await act.Should().ThrowAsync();
- ex.Which.InnerExceptions.Should().HaveCount(1);
-
- // But both successful handlers should have executed despite the failure
- successHandler1.Executed.Should().BeTrue();
- successHandler1.ReceivedEvent.Should().BeEquivalentTo(testEvent);
- successHandler2.Executed.Should().BeTrue();
- successHandler2.ReceivedEvent.Should().BeEquivalentTo(testEvent);
- }
-
- ///
- /// Test: Exceptions from multiple failing handlers are all collected and thrown together.
- ///
- [Fact]
- public async Task PublishAsync_WhenMultipleHandlersFail_AllExceptionsAreCollected()
- {
- var exception1 = new InvalidOperationException("Handler 1 error");
- var exception2 = new ArgumentException("Handler 2 error");
- var exception3 = new NotImplementedException("Handler 3 error");
-
- var services = new ServiceCollection();
- services.AddSingleton>(new FailingHandler(exception1));
- services.AddSingleton>(new FailingHandler(exception2));
- services.AddSingleton>(new FailingHandler(exception3));
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- var act = async () => await bus.PublishAsync(new TestEvent("test"));
-
- var ex = await act.Should().ThrowAsync();
- ex.Which.InnerExceptions.Should().HaveCount(3);
- ex.Which.InnerExceptions.Should().Contain(exception1);
- ex.Which.InnerExceptions.Should().Contain(exception2);
- ex.Which.InnerExceptions.Should().Contain(exception3);
- }
-
- ///
- /// Test: Handlers execute in deterministic order (registration order).
- /// Even when some fail, the order is preserved.
- ///
- [Fact]
- public async Task PublishAsync_ExecutionOrder_IsDeterministic_RegistrationOrder()
- {
- var executionOrder = new List();
- var services = new ServiceCollection();
-
- // Register handlers in a specific order
- services.AddSingleton>(
- new OrderTrackingHandler(executionOrder, "Handler-1")
- );
- services.AddSingleton>(
- new OrderTrackingHandler(executionOrder, "Handler-2")
- );
- services.AddSingleton>(
- new OrderTrackingHandler(executionOrder, "Handler-3")
- );
-
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- await bus.PublishAsync(new TestEvent("test"));
-
- // Handlers should execute in registration order
- executionOrder.Should().ContainInOrder("Handler-1", "Handler-2", "Handler-3");
- }
-
- ///
- /// Test: Handler execution order is preserved even when some handlers fail.
- /// A failing handler in the middle doesn't disrupt the sequence.
- ///
- [Fact]
- public async Task PublishAsync_ExecutionOrder_PreservedEvenWhenSomeFail()
- {
- var executionOrder = new List();
- var exception = new InvalidOperationException("Intentional failure");
- var services = new ServiceCollection();
-
- services.AddSingleton>(
- new OrderTrackingHandler(executionOrder, "Handler-1")
- );
- services.AddSingleton>(
- new OrderTrackingHandler(executionOrder, "Handler-2", exception)
- );
- services.AddSingleton>(
- new OrderTrackingHandler(executionOrder, "Handler-3")
- );
- services.AddSingleton>(
- new OrderTrackingHandler(executionOrder, "Handler-4")
- );
-
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- var act = async () => await bus.PublishAsync(new TestEvent("test"));
-
- // Should throw but handlers should still execute in order
- await act.Should().ThrowAsync();
- executionOrder.Should().ContainInOrder("Handler-1", "Handler-2", "Handler-3", "Handler-4");
- }
-
- ///
- /// Test: Successful handlers complete their work even if later handlers fail.
- /// This verifies that side effects from successful handlers are preserved.
- ///
- [Fact]
- public async Task PublishAsync_SuccessfulHandlers_CompleteSideEffects_DespiteLaterFailures()
- {
- var completedWork = new List();
-
- var successHandler1 = new OrderTrackingHandler(completedWork, "Work-1");
- var failingHandler = new FailingHandler(new InvalidOperationException("Failure"));
- var successHandler2 = new OrderTrackingHandler(completedWork, "Work-2");
- var successHandler3 = new OrderTrackingHandler(completedWork, "Work-3");
-
- var services = new ServiceCollection();
- services.AddSingleton>(successHandler1);
- services.AddSingleton>(failingHandler);
- services.AddSingleton>(successHandler2);
- services.AddSingleton>(successHandler3);
- var provider = services.BuildServiceProvider();
- var bus = new EventBus(
- provider,
- NullLogger.Instance,
- new BackgroundEventChannel(NullLogger.Instance)
- );
-
- var act = async () => await bus.PublishAsync(new TestEvent("test"));
-
- // Should throw due to the failing handler
- await act.Should().ThrowAsync();
-
- // But all successful handlers should have completed their work
- completedWork.Should().ContainInOrder("Work-1", "Work-2", "Work-3");
- }
-
- ///