From 3809a1398e5a671e6a24d4f41e3d3a9194556cda Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 22:05:39 +0200 Subject: [PATCH 1/5] Add WolverineFx and wire UseWolverine alongside existing IEventBus Phase 1 of the IEventBus -> Wolverine migration. Introduces WolverineFx 5.31 and Scrutor 7.0 packages. The old custom EventBus/BackgroundEvent infrastructure remains registered so no runtime behavior changes yet; subsequent phases will migrate modules to IMessageBus and delete the custom implementation. --- Directory.Packages.props | 3 +++ framework/SimpleModule.Core/SimpleModule.Core.csproj | 1 + framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs | 5 +++++ .../SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs | 5 +++++ .../src/SimpleModule.AuditLogs/SimpleModule.AuditLogs.csproj | 1 + 5 files changed, 15 insertions(+) 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/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.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 4ddb2968..d38fc2da 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -23,6 +23,7 @@ using SimpleModule.Hosting.Inertia; using SimpleModule.Hosting.Middleware; using SimpleModule.Hosting.RateLimiting; +using Wolverine; using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Hosting; @@ -81,6 +82,10 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( 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(_ => { }); builder.Services.AddScoped(); // Required by EntityInterceptor to access the current HTTP context diff --git a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs index b8e47e2e..c526ae18 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Hosting; using SimpleModule.Core.Events; using SimpleModule.Database.Interceptors; +using Wolverine; using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.Hosting; @@ -43,6 +44,10 @@ public static HostApplicationBuilder AddSimpleModuleWorker(this HostApplicationB sp.GetRequiredService() )); + // Wolverine: in-process messaging only. Handlers are auto-discovered + // from loaded assemblies. No external transports, no message persistence. + builder.UseWolverine(_ => { }); + // HttpContextAccessor is required by EntityInterceptor even in a worker // (it returns null in non-HTTP contexts, which the interceptor handles gracefully). builder.Services.AddHttpContextAccessor(); 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 @@ + From 1cd31b236442a7256e12c7d2571a9504e6f69a64 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 22:11:53 +0200 Subject: [PATCH 2/5] Route Wolverine IMessageBus through AuditingMessageBus decorator Adds a new AuditingMessageBus that decorates Wolverine's IMessageBus via Scrutor and captures an AuditEntry for every IEvent published, sent, or invoked. The audit-extraction logic is factored out into a shared AuditEntryExtractor so AuditingEventBus and AuditingMessageBus produce identical entries during the migration; AuditingEventBus is deleted in a later phase once modules stop using IEventBus. DomainEventInterceptor now dispatches via IMessageBus. Wolverine routes by the runtime type of the message (message.GetType()), so no reflection is needed to preserve concrete-type dispatch. --- .../Interceptors/DomainEventInterceptor.cs | 29 +--- .../SimpleModule.AuditLogs/AuditLogsModule.cs | 6 + .../Pipeline/AuditEntryExtractor.cs | 79 +++++++++ .../Pipeline/AuditingEventBus.cs | 70 +------- .../Pipeline/AuditingMessageBus.cs | 163 ++++++++++++++++++ 5 files changed, 259 insertions(+), 88 deletions(-) create mode 100644 modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditEntryExtractor.cs create mode 100644 modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingMessageBus.cs 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/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs index d6ed7eee..9a61b29a 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs @@ -12,6 +12,7 @@ using SimpleModule.Core.Settings; using SimpleModule.Database; using SimpleModule.Settings.Contracts; +using Wolverine; namespace SimpleModule.AuditLogs; @@ -43,6 +44,11 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config 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/AuditEntryExtractor.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditEntryExtractor.cs new file mode 100644 index 00000000..a1c23a66 --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditEntryExtractor.cs @@ -0,0 +1,79 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; +using SimpleModule.AuditLogs.Contracts; +using SimpleModule.Core.Events; + +namespace SimpleModule.AuditLogs.Pipeline; + +/// +/// Reflects over an instance to produce a matching +/// . Shared by and +/// so the extraction rules don't diverge +/// during the migration from the custom event bus to Wolverine. +/// +internal static partial class AuditEntryExtractor +{ + [GeneratedRegex( + @"^(?.+?)(?Created|Updated|Deleted|Viewed|Exported|LoginSuccess|LoginFailed|PermissionGranted|PermissionRevoked|SettingChanged)Event$" + )] + private static partial Regex EventNamePattern(); + + public static AuditEntry Extract(IEvent evt, IAuditContext auditContext) + { + var eventType = evt.GetType(); + var typeName = eventType.Name; + var match = EventNamePattern().Match(typeName); + + string? module = null; + AuditAction action = AuditAction.Other; + string? entityType = null; + string? entityId = null; + Dictionary? metadata = null; + + if (match.Success) + { + entityType = match.Groups["entity"].Value; + module = entityType; + if (Enum.TryParse(match.Groups["action"].Value, out var parsed)) + { + action = parsed; + } + } + + var properties = eventType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var prop in properties) + { + var value = prop.GetValue(evt); + if (prop.Name.EndsWith("Id", StringComparison.Ordinal) && value is not null) + { + entityId ??= value.ToString(); + if (entityType is null && prop.Name.Length > 2) + { + entityType = prop.Name[..^2]; + module ??= entityType; + } + } + else if (value is not null) + { + metadata ??= []; + metadata[prop.Name] = value; + } + } + + return new AuditEntry + { + CorrelationId = auditContext.CorrelationId, + Source = AuditSource.Domain, + Timestamp = DateTimeOffset.UtcNow, + UserId = auditContext.UserId, + UserName = auditContext.UserName, + IpAddress = auditContext.IpAddress, + Module = module, + EntityType = entityType, + EntityId = entityId, + Action = action, + Metadata = metadata is not null ? JsonSerializer.Serialize(metadata) : null, + }; + } +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs index d58cc7c6..c3780fb1 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs @@ -1,6 +1,3 @@ -using System.Reflection; -using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using SimpleModule.AuditLogs.Contracts; using SimpleModule.Core.Events; @@ -9,7 +6,7 @@ namespace SimpleModule.AuditLogs.Pipeline; -public sealed partial class AuditingEventBus( +public sealed class AuditingEventBus( IEventBus inner, IAuditContext auditContext, AuditChannel channel, @@ -17,11 +14,6 @@ public sealed partial class AuditingEventBus( ILogger? logger = null ) : IEventBus { - [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 { @@ -38,7 +30,7 @@ settings is null { try { - var entry = ExtractAuditEntry(@event); + var entry = AuditEntryExtractor.Extract(@event, auditContext); channel.Enqueue(entry); } catch (OperationCanceledException) @@ -63,62 +55,4 @@ public void PublishInBackground(T @event) { inner.PublishInBackground(@event); } - - private AuditEntry ExtractAuditEntry(T @event) - where T : IEvent - { - var typeName = typeof(T).Name; - var match = EventNamePattern().Match(typeName); - - string? module = null; - AuditAction action = AuditAction.Other; - string? entityType = null; - string? entityId = null; - Dictionary? metadata = null; - - if (match.Success) - { - entityType = match.Groups["entity"].Value; - module = entityType; - if (Enum.TryParse(match.Groups["action"].Value, out var parsed)) - { - action = parsed; - } - } - - var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); - foreach (var prop in properties) - { - var value = prop.GetValue(@event); - if (prop.Name.EndsWith("Id", StringComparison.Ordinal) && value is not null) - { - entityId ??= value.ToString(); - if (entityType is null && prop.Name.Length > 2) - { - entityType = prop.Name[..^2]; - module ??= entityType; - } - } - else if (value is not null) - { - metadata ??= []; - metadata[prop.Name] = value; - } - } - - return new AuditEntry - { - CorrelationId = auditContext.CorrelationId, - Source = AuditSource.Domain, - Timestamp = DateTimeOffset.UtcNow, - UserId = auditContext.UserId, - UserName = auditContext.UserName, - IpAddress = auditContext.IpAddress, - Module = module, - EntityType = entityType, - EntityId = entityId, - Action = action, - Metadata = metadata is not null ? JsonSerializer.Serialize(metadata) : null, - }; - } } 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 + } +} From c22bda923b9eea4c3d1f2d77324194f81635f75d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 22:23:05 +0200 Subject: [PATCH 3/5] Migrate all module publishers from IEventBus to Wolverine IMessageBus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps 22 files across Users, Tenants, Products, Orders, PageBuilder, FileStorage, Email, Datasets, FeatureFlags, and Settings. The default publish semantic is Wolverine's PublishAsync (fire-and-forget to the local queue), which matches the fact that no modules currently register IEventHandler handlers — events are broadcast-only today. PublishInBackground (formerly fire-and-forget on the custom bus) and PublishAsync (formerly sync-with-propagation) both map to Wolverine's PublishAsync since no callers actually observe handler failures today. SettingsService keeps its Lazy<> indirection (now Lazy) to break the SettingsService -> AuditingMessageBus -> ISettingsContracts construction cycle. Test stubs migrate to a shared TestMessageBus and to NSubstitute. The old IEventBus registration and AuditingEventBus remain in place until Phase 4 cleans them up. The CLI module template (ModuleTemplates.cs) now scaffolds IMessageBus for new modules. --- .../Templates/ModuleTemplates.cs | 2 +- .../SimpleModuleHostExtensions.cs | 5 + .../SimpleModuleWorkerExtensions.cs | 5 + .../Jobs/ConvertDatasetJob.cs | 9 +- .../Jobs/ProcessDatasetJob.cs | 9 +- .../EmailService.Templates.cs | 6 +- .../src/SimpleModule.Email/EmailService.cs | 4 +- .../Jobs/RetryFailedEmailsJob.cs | 6 +- .../SimpleModule.Email/Jobs/SendEmailJob.cs | 10 +- .../Unit/EmailServiceTests.cs | 25 +--- .../FeatureFlags/SetOverrideEndpoint.cs | 6 +- .../Endpoints/FeatureFlags/UpdateEndpoint.cs | 6 +- .../FileStorageService.cs | 8 +- .../FileStorageServiceTests.cs | 4 +- .../src/SimpleModule.Orders/OrderService.cs | 6 +- .../Unit/OrderServiceTests.cs | 12 +- .../PageBuilderService.cs | 12 +- .../PageBuilderServiceTests.Helpers.cs | 2 +- .../SimpleModule.Products/ProductService.cs | 14 +-- .../Unit/ProductServiceTests.cs | 2 +- .../SimpleModule.Settings/SettingsService.cs | 10 +- .../SimpleModule.Settings.Tests.csproj | 1 + .../Unit/SettingsServiceTests.cs | 5 +- .../src/SimpleModule.Tenants/TenantService.cs | 14 +-- .../SimpleModule.Tenants.Tests.csproj | 1 + .../Unit/TenantServiceTests.cs | 49 +++----- .../SimpleModule.Users/UserAdminService.cs | 10 +- .../src/SimpleModule.Users/UserService.cs | 10 +- .../Unit/UserServiceTests.cs | 2 +- .../DomainEventInterceptorTests.cs | 112 ++++++++++++++---- .../Fakes/TestMessageBus.cs | 106 +++++++++++++++++ 31 files changed, 304 insertions(+), 169 deletions(-) create mode 100644 tests/SimpleModule.Tests.Shared/Fakes/TestMessageBus.cs 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.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index d38fc2da..041fde33 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -86,6 +86,11 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( // 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(); // Required by EntityInterceptor to access the current HTTP context diff --git a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs index c526ae18..c24efc26 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs @@ -47,6 +47,11 @@ public static HostApplicationBuilder AddSimpleModuleWorker(this HostApplicationB // 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 // (it returns null in non-HTTP contexts, which the interceptor handles gracefully). 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.Database.Tests/DomainEventInterceptorTests.cs b/tests/SimpleModule.Database.Tests/DomainEventInterceptorTests.cs index eb857c45..62889c46 100644 --- a/tests/SimpleModule.Database.Tests/DomainEventInterceptorTests.cs +++ b/tests/SimpleModule.Database.Tests/DomainEventInterceptorTests.cs @@ -7,6 +7,7 @@ using SimpleModule.Core.Entities; using SimpleModule.Core.Events; using SimpleModule.Database.Interceptors; +using Wolverine; namespace SimpleModule.Database.Tests; @@ -15,8 +16,8 @@ public sealed class DomainEventInterceptorTests [Fact] public async Task Domain_Events_Dispatched_After_SaveChanges() { - var eventBus = new TestEventBus(); - await using var fixture = CreateFixture(eventBus); + var bus = new RecordingMessageBus(); + await using var fixture = CreateFixture(bus); var entity = new AggregateRootTestEntity { Name = "Test" }; entity.TriggerSomethingHappened(); @@ -24,15 +25,15 @@ public async Task Domain_Events_Dispatched_After_SaveChanges() fixture.Context.AggregateRoots.Add(entity); await fixture.Context.SaveChangesAsync(); - eventBus.PublishedEvents.Should().HaveCount(1); - eventBus.PublishedEvents[0].Should().BeOfType(); + bus.PublishedMessages.Should().HaveCount(1); + bus.PublishedMessages[0].Should().BeOfType(); } [Fact] public async Task Domain_Events_Cleared_After_Dispatch() { - var eventBus = new TestEventBus(); - await using var fixture = CreateFixture(eventBus); + var bus = new RecordingMessageBus(); + await using var fixture = CreateFixture(bus); var entity = new AggregateRootTestEntity { Name = "Test" }; entity.TriggerSomethingHappened(); @@ -46,22 +47,22 @@ public async Task Domain_Events_Cleared_After_Dispatch() [Fact] public async Task No_Events_Dispatched_When_No_Domain_Events() { - var eventBus = new TestEventBus(); - await using var fixture = CreateFixture(eventBus); + var bus = new RecordingMessageBus(); + await using var fixture = CreateFixture(bus); var entity = new AggregateRootTestEntity { Name = "Test" }; fixture.Context.AggregateRoots.Add(entity); await fixture.Context.SaveChangesAsync(); - eventBus.PublishedEvents.Should().BeEmpty(); + bus.PublishedMessages.Should().BeEmpty(); } [Fact] public async Task Multiple_Events_From_Multiple_Entities_All_Dispatched() { - var eventBus = new TestEventBus(); - await using var fixture = CreateFixture(eventBus); + var bus = new RecordingMessageBus(); + await using var fixture = CreateFixture(bus); var entity1 = new AggregateRootTestEntity { Name = "First" }; entity1.TriggerSomethingHappened(); @@ -73,10 +74,10 @@ public async Task Multiple_Events_From_Multiple_Entities_All_Dispatched() fixture.Context.AggregateRoots.AddRange(entity1, entity2); await fixture.Context.SaveChangesAsync(); - eventBus.PublishedEvents.Should().HaveCount(3); + bus.PublishedMessages.Should().HaveCount(3); } - private static TestFixture CreateFixture(IEventBus eventBus) + private static TestFixture CreateFixture(IMessageBus bus) { var config = new ConfigurationBuilder() .AddInMemoryCollection( @@ -88,7 +89,7 @@ private static TestFixture CreateFixture(IEventBus eventBus) .Build(); var services = new ServiceCollection(); - services.AddSingleton(eventBus); + services.AddSingleton(bus); services.AddScoped(); services.AddModuleDbContext(config, "DomainEventTest"); @@ -129,22 +130,83 @@ public class AggregateRootTestEntity : IHasDomainEvents public void TriggerSomethingHappened() => _events.Add(new SomethingHappenedEvent()); } -public sealed class TestEventBus : IEventBus +/// +/// Recording IMessageBus that captures PublishAsync calls for assertion. +/// Other IMessageBus methods throw — the interceptor only uses PublishAsync. +/// +public sealed class RecordingMessageBus : IMessageBus { - public List PublishedEvents { get; } = []; + public List PublishedMessages { get; } = []; - public Task PublishAsync(T @event, CancellationToken cancellationToken = default) - where T : IEvent - { - PublishedEvents.Add(@event); - return Task.CompletedTask; - } + public string? TenantId { get; set; } - public void PublishInBackground(T @event) - where T : IEvent + public ValueTask PublishAsync(T message, DeliveryOptions? options = null) { - PublishedEvents.Add(@event); + if (message is not null) + { + PublishedMessages.Add(message); + } + return ValueTask.CompletedTask; } + + public ValueTask SendAsync(T message, DeliveryOptions? options = null) => + throw new NotImplementedException(); + + public Task InvokeAsync( + object message, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) => throw new NotImplementedException(); + + public Task InvokeAsync( + object message, + DeliveryOptions options, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) => throw new NotImplementedException(); + + public Task InvokeAsync( + object message, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) => throw new NotImplementedException(); + + public Task InvokeAsync( + object message, + DeliveryOptions options, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) => throw new NotImplementedException(); + + public Task InvokeForTenantAsync( + string tenantId, + object message, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) => throw new NotImplementedException(); + + public Task InvokeForTenantAsync( + string tenantId, + object message, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) => throw new NotImplementedException(); + + public IDestinationEndpoint EndpointFor(string endpointName) => + throw new NotImplementedException(); + + public IDestinationEndpoint EndpointFor(Uri uri) => throw new NotImplementedException(); + + public IReadOnlyList PreviewSubscriptions(object message) => []; + + public IReadOnlyList PreviewSubscriptions(object message, DeliveryOptions options) => + []; + + public ValueTask BroadcastToTopicAsync( + string topicName, + object message, + DeliveryOptions? options = null + ) => throw new NotImplementedException(); } public class DomainEventTestDbContext( diff --git a/tests/SimpleModule.Tests.Shared/Fakes/TestMessageBus.cs b/tests/SimpleModule.Tests.Shared/Fakes/TestMessageBus.cs new file mode 100644 index 00000000..354c063b --- /dev/null +++ b/tests/SimpleModule.Tests.Shared/Fakes/TestMessageBus.cs @@ -0,0 +1,106 @@ +using Wolverine; + +namespace SimpleModule.Tests.Shared.Fakes; + +/// +/// Recording stub for unit tests. Captures every +/// message published, sent, or invoked in so +/// tests can assert on publishing behaviour without wiring up Wolverine. +/// Methods unrelated to local publish/invoke are no-ops. +/// +public sealed class TestMessageBus : IMessageBus +{ + public List PublishedEvents { get; } = []; + + public string? TenantId { get; set; } + + public ValueTask PublishAsync(T message, DeliveryOptions? options = null) + { + if (message is not null) + { + PublishedEvents.Add(message); + } + return ValueTask.CompletedTask; + } + + public ValueTask SendAsync(T message, DeliveryOptions? options = null) + { + if (message is not null) + { + PublishedEvents.Add(message); + } + return ValueTask.CompletedTask; + } + + public Task InvokeAsync( + object message, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) + { + PublishedEvents.Add(message); + return Task.CompletedTask; + } + + public Task InvokeAsync( + object message, + DeliveryOptions options, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) + { + PublishedEvents.Add(message); + return Task.CompletedTask; + } + + public Task InvokeAsync( + object message, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) => throw new NotImplementedException(); + + public Task InvokeAsync( + object message, + DeliveryOptions options, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) => throw new NotImplementedException(); + + public Task InvokeForTenantAsync( + string tenantId, + object message, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) + { + PublishedEvents.Add(message); + return Task.CompletedTask; + } + + public Task InvokeForTenantAsync( + string tenantId, + object message, + CancellationToken cancellation = default, + TimeSpan? timeout = default + ) => throw new NotImplementedException(); + + public IDestinationEndpoint EndpointFor(string endpointName) => + throw new NotImplementedException(); + + public IDestinationEndpoint EndpointFor(Uri uri) => throw new NotImplementedException(); + + public IReadOnlyList PreviewSubscriptions(object message) => []; + + public IReadOnlyList PreviewSubscriptions(object message, DeliveryOptions options) => + []; + + public ValueTask BroadcastToTopicAsync( + string topicName, + object message, + DeliveryOptions? options = null + ) + { + PublishedEvents.Add(message); + return ValueTask.CompletedTask; + } +} From 899aae81d931b469c74cce7cab1bada205a14e40 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 22:26:39 +0200 Subject: [PATCH 4/5] Delete custom IEventBus infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the Wolverine migration. Removes the now-unused custom event bus, its tests, the shared TestEventBus fake, and AuditingEventBus (replaced by AuditingMessageBus in phase 2). The IEvent marker interface is kept — it costs nothing and remains a documentation hint about which types are domain events. The SM0010 circular-dependency diagnostic now suggests Wolverine handlers instead of IEventHandler. Doc comments on IHasDomainEvents and AuditableAggregateRoot updated to reference IMessageBus. Net deletion: ~600 LOC across seven framework files, four framework tests, the shared fake, and AuditingEventBus + its tests. --- .../Entities/AuditableAggregateRoot.cs | 2 +- .../Entities/IHasDomainEvents.cs | 2 +- .../Events/BackgroundEventChannel.cs | 45 ---- .../Events/BackgroundEventDispatcher.cs | 46 ---- .../SimpleModule.Core/Events/EventBus.cs | 178 ------------- .../SimpleModule.Core/Events/IEventBus.cs | 46 ---- .../SimpleModule.Core/Events/IEventHandler.cs | 74 ------ .../Events/IEventPipelineBehavior.cs | 30 --- .../Emitters/DiagnosticEmitter.cs | 2 +- .../SimpleModuleHostExtensions.cs | 10 - .../SimpleModuleWorkerExtensions.cs | 9 - .../SimpleModule.AuditLogs/AuditLogsModule.cs | 11 - .../Pipeline/AuditingEventBus.cs | 58 ----- .../Unit/AuditingEventBusTests.cs | 164 ------------ .../SimpleModule.Core.Tests/EventBusTests.cs | 215 --------------- .../Events/EventBusIntegrationTests.cs | 244 ------------------ ...ventBusPartialFailureTests.Cancellation.cs | 128 --------- .../EventBusPartialFailureTests.Helpers.cs | 80 ------ .../Events/EventBusPartialFailureTests.cs | 208 --------------- .../WebApplicationFactoryTests.cs | 22 +- .../Fakes/TestEventBus.cs | 23 -- 21 files changed, 15 insertions(+), 1582 deletions(-) delete mode 100644 framework/SimpleModule.Core/Events/BackgroundEventChannel.cs delete mode 100644 framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs delete mode 100644 framework/SimpleModule.Core/Events/EventBus.cs delete mode 100644 framework/SimpleModule.Core/Events/IEventBus.cs delete mode 100644 framework/SimpleModule.Core/Events/IEventHandler.cs delete mode 100644 framework/SimpleModule.Core/Events/IEventPipelineBehavior.cs delete mode 100644 modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs delete mode 100644 modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditingEventBusTests.cs delete mode 100644 tests/SimpleModule.Core.Tests/EventBusTests.cs delete mode 100644 tests/SimpleModule.Core.Tests/Events/EventBusIntegrationTests.cs delete mode 100644 tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.Cancellation.cs delete mode 100644 tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.Helpers.cs delete mode 100644 tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.cs delete mode 100644 tests/SimpleModule.Tests.Shared/Fakes/TestEventBus.cs 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.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index caaad47d..31c9b9ba 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -95,7 +95,7 @@ internal sealed class DiagnosticEmitter : IEmitter 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 041fde33..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; @@ -74,15 +73,6 @@ 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(_ => { }); diff --git a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs index c24efc26..f1e62e62 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs @@ -3,7 +3,6 @@ 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; @@ -35,14 +34,6 @@ 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. diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs index 9a61b29a..5ed0f08e 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModule.cs @@ -8,7 +8,6 @@ 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; @@ -35,16 +34,6 @@ 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. diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs deleted file mode 100644 index c3780fb1..00000000 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs +++ /dev/null @@ -1,58 +0,0 @@ -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 class AuditingEventBus( - IEventBus inner, - IAuditContext auditContext, - AuditChannel channel, - ISettingsContracts? settings = null, - ILogger? logger = null -) : IEventBus -{ - public async Task PublishAsync(T @event, CancellationToken cancellationToken = default) - where T : IEvent - { - // 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 = AuditEntryExtractor.Extract(@event, auditContext); - 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); - } -} 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/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"); - } - - /// - /// Test: AggregateException message is informative and includes event type. - /// - [Fact] - public async Task PublishAsync_AggregateException_IncludesEventTypeName() - { - var services = new ServiceCollection(); - services.AddSingleton>(new FailingHandler()); - 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.Message.Should().Contain("TestEvent"); - } -} diff --git a/tests/SimpleModule.Core.Tests/Infrastructure/WebApplicationFactoryTests.cs b/tests/SimpleModule.Core.Tests/Infrastructure/WebApplicationFactoryTests.cs index 3d555db9..c5011874 100644 --- a/tests/SimpleModule.Core.Tests/Infrastructure/WebApplicationFactoryTests.cs +++ b/tests/SimpleModule.Core.Tests/Infrastructure/WebApplicationFactoryTests.cs @@ -9,6 +9,7 @@ using SimpleModule.Core.Events; using SimpleModule.Host; using SimpleModule.Tests.Shared.Fixtures; +using Wolverine; namespace SimpleModule.Core.Tests.Infrastructure; @@ -155,9 +156,10 @@ public void AuditLogContracts_CanBeResolved() // Defends against runtime circular dependencies that SM0010 can't catch: // the generator only sees module-level project references and cannot analyze - // factory lambdas like AddScoped(sp => new AuditingEventBus(..., sp.GetService())). - // .NET DI can't detect re-entry through factory lambdas, so such cycles hang - // the whole test run instead of throwing. These timeout tests fail fast instead. + // factory lambdas like services.Decorate() + // with an optional ISettingsContracts parameter. .NET DI can't detect re-entry + // through factory lambdas, so such cycles hang the whole test run instead of + // throwing. These timeout tests fail fast instead. private static readonly TimeSpan ResolutionTimeout = TimeSpan.FromSeconds(5); @@ -167,19 +169,19 @@ public Task ContractInterface_CanBeResolved_WithoutHanging(Type contractType) => AssertResolvesWithinTimeout(contractType); [Fact] - public Task EventBus_CanBeResolved_WithoutHanging() => - AssertResolvesWithinTimeout(typeof(IEventBus)); + public Task MessageBus_CanBeResolved_WithoutHanging() => + AssertResolvesWithinTimeout(typeof(IMessageBus)); [Fact] - public async Task EventBus_CanPublishEvent_WithoutHanging() + public async Task MessageBus_CanPublishEvent_WithoutHanging() { var rootProvider = _factory.Services; var publishTask = Task.Run(async () => { using var scope = rootProvider.CreateScope(); - var bus = scope.ServiceProvider.GetRequiredService(); - await bus.PublishAsync(new NoopEvent(), CancellationToken.None); + var bus = scope.ServiceProvider.GetRequiredService(); + await bus.PublishAsync(new NoopEvent()); }); try @@ -189,7 +191,7 @@ public async Task EventBus_CanPublishEvent_WithoutHanging() catch (TimeoutException) { throw new InvalidOperationException( - $"IEventBus.PublishAsync hung for over {ResolutionTimeout.TotalSeconds}s. " + $"IMessageBus.PublishAsync hung for over {ResolutionTimeout.TotalSeconds}s. " + "A handler, decorator, or service in the resolution chain likely has a circular dependency." ); } @@ -217,7 +219,7 @@ private async Task AssertResolvesWithinTimeout(Type serviceType) throw new InvalidOperationException( $"Resolving '{serviceType.FullName}' hung for over {ResolutionTimeout.TotalSeconds}s. " + "Likely a circular dependency introduced through a decorator factory " - + "(see IEventBus → AuditingEventBus → ISettingsContracts pattern)." + + "(see IMessageBus → AuditingMessageBus → ISettingsContracts pattern)." ); } } diff --git a/tests/SimpleModule.Tests.Shared/Fakes/TestEventBus.cs b/tests/SimpleModule.Tests.Shared/Fakes/TestEventBus.cs deleted file mode 100644 index fa6fc830..00000000 --- a/tests/SimpleModule.Tests.Shared/Fakes/TestEventBus.cs +++ /dev/null @@ -1,23 +0,0 @@ -using SimpleModule.Core.Events; - -namespace SimpleModule.Tests.Shared.Fakes; - -/// -/// Recording stub for unit tests. Captures every published -/// event in so tests can assert on publishing behaviour -/// without wiring up the full event pipeline. -/// -public 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); -} From 40e83ec96bd3d1226428f0cf39d3f97e6bb1d94a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 22:35:12 +0200 Subject: [PATCH 5/5] Update cross-module skill and AuditEntryExtractor doc for Wolverine Rewrites the event-bus section of .claude/skills/simplemodule/ references/cross-module.md to describe Wolverine's IMessageBus, convention-based handler discovery, and inline/queued publish semantics. Also fixes a dangling cref on AuditEntryExtractor that pointed to the deleted AuditingEventBus class. --- .../simplemodule/references/cross-module.md | 47 ++++++++++++------- .../Pipeline/AuditEntryExtractor.cs | 5 +- 2 files changed, 33 insertions(+), 19 deletions(-) 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/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditEntryExtractor.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditEntryExtractor.cs index a1c23a66..4e461bf3 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditEntryExtractor.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditEntryExtractor.cs @@ -8,9 +8,8 @@ namespace SimpleModule.AuditLogs.Pipeline; /// /// Reflects over an instance to produce a matching -/// . Shared by and -/// so the extraction rules don't diverge -/// during the migration from the custom event bus to Wolverine. +/// . Used by +/// to build audit entries for every published domain event. /// internal static partial class AuditEntryExtractor {