diff --git a/.gitignore b/.gitignore
index 663bb008..892dc204 100644
--- a/.gitignore
+++ b/.gitignore
@@ -443,3 +443,4 @@ template/SimpleModule.Host/storage/
# Temporary refactor baseline — not committed
baseline/
+.claude/settings.local.json
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4e24d7e0..4a16ef67 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -65,6 +65,10 @@
+
+
+
+
diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md
index 69a89085..5321b7b2 100644
--- a/docs/CONSTITUTION.md
+++ b/docs/CONSTITUTION.md
@@ -195,11 +195,15 @@ This is cosmetic organization -- all modules share one connection.
### Events
- Cross-module notifications use Wolverine's `IMessageBus.PublishAsync()`
-- Events are records implementing the `IEvent` marker, defined in the publishing module's Contracts project
-- Any module can handle any event by declaring a class with a `Handle` / `Consume` / `HandleAsync` method taking the event as its first parameter — Wolverine discovers handlers by naming convention
-- In-process only: no external transports, no durable outbox, no cross-restart persistence. For durable work use the Background Jobs module.
-- Handlers should be stateless, independent, and idempotent
-- Non-critical handlers (audit, metrics, cache invalidation) should catch their own exceptions so they never break the primary operation
+- Events are records deriving from `DomainEvent` (which implements the `IEvent` marker), defined in the publishing module's Contracts project. The `DomainEvent` base supplies a stable `EventId` and `OccurredAt` so the durable inbox can deduplicate redelivery.
+- Any module can handle any event by declaring a class with a `Handle` / `Consume` / `HandleAsync` method taking the event as its first parameter — Wolverine discovers handlers by naming convention.
+- Handler discovery is automatic across every module assembly — the host's source-generated `AddSimpleModule()` registers all module assemblies with Wolverine, so handlers do not need a per-module `[WolverineModule]` attribute or `IWolverineExtension` class.
+- Messaging is durable: Wolverine persists every published envelope to the configured database (SQLite, PostgreSQL, or SQL Server) via `WolverineFx.{Provider}` before dispatch, and every local listener is enrolled in the durable inbox so each handler chain processes a given `EventId` at most once — even across process restarts. Schema is auto-created on startup.
+- Most service-level publishes are **atomic with the business write**: services inject `IDbContextOutbox`, stage entity changes, call `outbox.PublishAsync(...)`, and finish with `outbox.SaveChangesAndFlushMessagesAsync()` so the EF write and the outbox envelope commit in the same transaction.
+- Events raised by `IHasDomainEvents` aggregates are scraped by Wolverine's `PublishDomainEventsFromEntityFrameworkCore(x => x.Events)` integration during the same transactional flush.
+- Three categories remain on the durable-but-non-atomic `bus.PublishAsync` pattern (microsecond commit→publish gap, mitigated by inbox dedup): (1) **create events with DB-generated identifiers** — the new ID is not known until after `SaveChangesAsync` returns; switching the affected identifiers (`TenantId`, `FileStorageId`, `EmailMessageId`, `EmailTemplateId`) to caller-generated `Guid.CreateVersion7()` would close the gap; (2) **`UserService` / `UserAdminService` and the self-service account endpoints** — ASP.NET Identity's `UserManager` owns its `SaveChanges` and cannot be enrolled in the outbox; (3) **`SettingsService`** — switching it to `IDbContextOutbox` re-introduces the DI cycle the existing `Lazy` was added to break (resolve by moving the `AuditingMessageBus` settings gate to a Wolverine middleware).
+- Handlers should be stateless, independent, and idempotent — the inbox dedup is a safety net, not a substitute for idempotency.
+- Wolverine logs handler exceptions and discards the message on first failure by default (`MaximumAttempts = 1` on local queues). Non-critical handlers (audit, metrics, cache invalidation) should still catch their own exceptions where the failure mode would otherwise produce noisy log spam.
### When to Use Which Communication Pattern
diff --git a/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs b/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs
index 810b237e..0302e1e7 100644
--- a/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs
+++ b/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs
@@ -4,15 +4,12 @@ namespace SimpleModule.Core.Entities;
///
/// Aggregate root with audit tracking, soft delete, versioning, and domain events.
-/// Domain events are automatically dispatched via Wolverine's IMessageBus after SaveChanges.
+/// Domain events added via are flushed to Wolverine's
+/// durable outbox during SaveChangesAsync, atomic with the EF transaction.
///
public abstract class AuditableAggregateRoot : FullAuditableEntity, IHasDomainEvents
{
- private readonly List _domainEvents = [];
+ public List Events { get; } = [];
- public IReadOnlyList GetDomainEvents() => _domainEvents.AsReadOnly();
-
- public void ClearDomainEvents() => _domainEvents.Clear();
-
- protected void AddDomainEvent(IEvent domainEvent) => _domainEvents.Add(domainEvent);
+ protected void AddDomainEvent(IEvent domainEvent) => Events.Add(domainEvent);
}
diff --git a/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs b/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs
index d77ad308..8ce61c9d 100644
--- a/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs
+++ b/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs
@@ -3,11 +3,12 @@
namespace SimpleModule.Core.Entities;
///
-/// Entities implementing this interface can raise domain events that are automatically
-/// dispatched via Wolverine's IMessageBus after a successful SaveChanges.
+/// Entities implementing this interface have their list scraped
+/// by Wolverine's PublishDomainEventsFromEntityFrameworkCore integration during
+/// SaveChangesAsync — events are written to the outbox in the same transaction
+/// as the EF business write, and the list is cleared after scrape.
///
public interface IHasDomainEvents
{
- IReadOnlyList GetDomainEvents();
- void ClearDomainEvents();
+ List Events { get; }
}
diff --git a/framework/SimpleModule.Core/Events/IEvent.cs b/framework/SimpleModule.Core/Events/IEvent.cs
index 8467e613..3b8a6c79 100644
--- a/framework/SimpleModule.Core/Events/IEvent.cs
+++ b/framework/SimpleModule.Core/Events/IEvent.cs
@@ -1,5 +1,24 @@
namespace SimpleModule.Core.Events;
-#pragma warning disable CA1040 // Avoid empty interfaces - marker interface by design
-public interface IEvent;
-#pragma warning restore CA1040
+///
+/// Marker contract for cross-module domain events. Carries a stable identifier and a
+/// timestamp so Wolverine's durable inbox can deduplicate redelivery and so audit
+/// pipelines have a consistent correlation key. Event records typically inherit
+/// rather than implementing this directly.
+///
+public interface IEvent
+{
+ Guid EventId { get; }
+ DateTimeOffset OccurredAt { get; }
+}
+
+///
+/// Base record for domain events. Derive from this to get a unique
+/// and an stamp without having to repeat the boilerplate
+/// on every event record.
+///
+public abstract record DomainEvent : IEvent
+{
+ public Guid EventId { get; init; } = Guid.CreateVersion7();
+ public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow;
+}
diff --git a/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs b/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs
deleted file mode 100644
index ab98fe3b..00000000
--- a/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-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 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 List? _collectedEvents;
-
- public override ValueTask> SavingChangesAsync(
- DbContextEventData eventData,
- InterceptionResult result,
- CancellationToken cancellationToken = default
- )
- {
- if (eventData.Context is not null)
- {
- var events = new List();
-
- foreach (var entry in eventData.Context.ChangeTracker.Entries())
- {
- var domainEvents = entry.Entity.GetDomainEvents();
- if (domainEvents.Count > 0)
- {
- events.AddRange(domainEvents);
- entry.Entity.ClearDomainEvents();
- }
- }
-
- _collectedEvents = events.Count > 0 ? events : null;
- }
-
- return base.SavingChangesAsync(eventData, result, cancellationToken);
- }
-
- public override async ValueTask SavedChangesAsync(
- SaveChangesCompletedEventData eventData,
- int result,
- CancellationToken cancellationToken = default
- )
- {
- var events = _collectedEvents;
- _collectedEvents = null;
-
- if (events is { Count: > 0 })
- {
- var bus = serviceProvider.GetService();
- if (bus is not null)
- {
- foreach (var domainEvent in events)
- {
- // 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);
- }
- }
- }
-
- return await base.SavedChangesAsync(eventData, result, cancellationToken);
- }
-
- public override Task SaveChangesFailedAsync(
- DbContextErrorEventData eventData,
- CancellationToken cancellationToken = default
- )
- {
- _collectedEvents = null;
- return base.SaveChangesFailedAsync(eventData, cancellationToken);
- }
-}
diff --git a/framework/SimpleModule.Database/SimpleModule.Database.csproj b/framework/SimpleModule.Database/SimpleModule.Database.csproj
index 591d6987..7be9312b 100644
--- a/framework/SimpleModule.Database/SimpleModule.Database.csproj
+++ b/framework/SimpleModule.Database/SimpleModule.Database.csproj
@@ -13,6 +13,7 @@
+
diff --git a/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs
index a8dd8491..92964a06 100644
--- a/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs
+++ b/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs
@@ -50,6 +50,9 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
sb.AppendLine(" SimpleModuleOptions? smOptions = null;");
sb.AppendLine(" builder.AddSimpleModuleInfrastructure(o =>");
sb.AppendLine(" {");
+ sb.AppendLine(
+ " o.ModuleAssemblies = global::SimpleModule.Core.ModuleExtensions.ModuleAssemblies;"
+ );
sb.AppendLine(" configure?.Invoke(o);");
sb.AppendLine(" smOptions = o;");
sb.AppendLine(" });");
diff --git a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs
index 2ebbd3d1..41b2bc92 100644
--- a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs
+++ b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs
@@ -44,6 +44,23 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
);
}
+ sb.AppendLine();
+ sb.AppendLine(
+ " /// Assemblies for every discovered module. Used by the host to register"
+ );
+ sb.AppendLine(
+ " /// Wolverine handler discovery for every module without per-module boilerplate."
+ );
+ sb.AppendLine(
+ " public static readonly global::System.Reflection.Assembly[] ModuleAssemblies = new global::System.Reflection.Assembly[]"
+ );
+ sb.AppendLine(" {");
+ foreach (var module in sortedModules)
+ {
+ sb.AppendLine($" typeof({module.FullyQualifiedName}).Assembly,");
+ }
+ sb.AppendLine(" };");
+
sb.AppendLine();
sb.AppendLine(
" public static IServiceCollection AddModules(this IServiceCollection services, IConfiguration configuration)"
diff --git a/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj b/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj
index b44ca090..aacd592f 100644
--- a/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj
+++ b/framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj
@@ -7,6 +7,10 @@
+
+
+
+
diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.Helpers.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.Helpers.cs
index f2bf48b5..d21d4060 100644
--- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.Helpers.cs
+++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.Helpers.cs
@@ -36,7 +36,7 @@ private static IResult RenderErrorPage(int statusCode)
);
}
- private static void BridgeAspireConnectionString(ConfigurationManager configuration)
+ internal static void BridgeAspireConnectionString(ConfigurationManager configuration)
{
var aspireConnectionString = configuration.GetConnectionString("simplemoduledb");
if (!string.IsNullOrEmpty(aspireConnectionString))
@@ -45,7 +45,7 @@ private static void BridgeAspireConnectionString(ConfigurationManager configurat
}
}
- private static DatabaseProvider ValidateDatabaseConfiguration(
+ internal static DatabaseProvider ValidateDatabaseConfiguration(
ConfigurationManager configuration
)
{
diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
index 7c6339cf..90888af0 100644
--- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
+++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
@@ -20,11 +20,12 @@
using SimpleModule.Database;
using SimpleModule.Database.Health;
using SimpleModule.Database.Interceptors;
+using JasperFx.Resources;
using SimpleModule.DevTools;
+using Wolverine;
using SimpleModule.Hosting.Inertia;
using SimpleModule.Hosting.Middleware;
using SimpleModule.Hosting.RateLimiting;
-using Wolverine;
using ZiggyCreatures.Caching.Fusion;
namespace SimpleModule.Hosting;
@@ -75,9 +76,22 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(
.Services.AddFusionCache()
.WithDefaultEntryOptions(o => o.Duration = TimeSpan.FromMinutes(5));
- // Wolverine: in-process messaging only. Handlers are auto-discovered
- // from loaded assemblies. No external transports, no message persistence.
- builder.Host.UseWolverine(_ => { });
+ var dbConnectionString =
+ builder.Configuration["Database:DefaultConnection"]
+ ?? throw new InvalidOperationException(
+ "Database:DefaultConnection must be configured for Wolverine durable messaging."
+ );
+
+ builder.Host.UseWolverine(opts =>
+ WolverineConfiguration.Configure(
+ opts,
+ options.ModuleAssemblies,
+ options.DatabaseProvider,
+ dbConnectionString
+ )
+ );
+
+ builder.Host.UseResourceSetupOnStartup();
// Lazy lets services break factory-lambda cycles
// (e.g. SettingsService ↔ AuditingMessageBus via ISettingsContracts).
builder.Services.AddScoped(sp => new Lazy(() =>
@@ -89,9 +103,7 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped();
- // Entity framework interceptors for automatic entity field population
builder.Services.AddScoped();
- builder.Services.AddScoped();
builder.Services.AddScoped();
// Authentication is configured by modules via their ConfigureServices
diff --git a/framework/SimpleModule.Hosting/SimpleModuleOptions.cs b/framework/SimpleModule.Hosting/SimpleModuleOptions.cs
index a3893d49..0a9e64fb 100644
--- a/framework/SimpleModule.Hosting/SimpleModuleOptions.cs
+++ b/framework/SimpleModule.Hosting/SimpleModuleOptions.cs
@@ -1,3 +1,4 @@
+using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using SimpleModule.Core;
using SimpleModule.Database;
@@ -8,6 +9,14 @@ public class SimpleModuleOptions
{
private readonly List> _moduleOptionsActions = [];
+ ///
+ /// Module assemblies to scan for Wolverine handlers. Set by the source-generated
+ /// AddSimpleModule() from ModuleExtensions.ModuleAssemblies; not
+ /// intended for user code.
+ ///
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ public IReadOnlyList ModuleAssemblies { get; set; } = [];
+
public bool EnableSwagger { get; set; } = true;
public bool EnableHealthChecks { get; set; } = true;
diff --git a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs
index f1e62e62..88563087 100644
--- a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs
+++ b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs
@@ -1,6 +1,6 @@
-using Microsoft.AspNetCore.Http;
+using System.Reflection;
+using JasperFx.Resources;
using Microsoft.EntityFrameworkCore.Diagnostics;
-using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SimpleModule.Database.Interceptors;
@@ -17,40 +17,52 @@ public static class SimpleModuleWorkerExtensions
/// forces BackgroundJobs into Consumer mode, wires the event bus and
/// EF interceptors, but skips all ASP.NET-specific middleware and endpoints.
///
- public static HostApplicationBuilder AddSimpleModuleWorker(this HostApplicationBuilder builder)
+ /// The host builder.
+ ///
+ /// Module assemblies to scan for Wolverine handlers. Pass
+ /// SimpleModule.Core.ModuleExtensions.ModuleAssemblies from your worker's
+ /// Program.cs. If empty, only the entry assembly is scanned, so handlers
+ /// living in other module assemblies will not be discovered.
+ ///
+ public static HostApplicationBuilder AddSimpleModuleWorker(
+ this HostApplicationBuilder builder,
+ params Assembly[] moduleAssemblies
+ )
{
- // Bridge Aspire connection string to the Database:DefaultConnection key,
- // matching the pattern used in AddSimpleModuleInfrastructure.
- var aspireConnectionString = builder.Configuration.GetConnectionString("simplemoduledb");
- if (!string.IsNullOrEmpty(aspireConnectionString))
- {
- builder.Configuration["Database:DefaultConnection"] = aspireConnectionString;
- }
+ SimpleModuleHostExtensions.BridgeAspireConnectionString(builder.Configuration);
// Force consumer mode regardless of config. User can still tune Worker:* options.
builder.Configuration["BackgroundJobs:WorkerMode"] = "Consumer";
- // Core infrastructure that the worker needs:
builder
.Services.AddFusionCache()
.WithDefaultEntryOptions(o => o.Duration = TimeSpan.FromMinutes(5));
- // 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).
+ var workerProvider = SimpleModuleHostExtensions.ValidateDatabaseConfiguration(
+ builder.Configuration
+ );
+ var dbConnectionString = builder.Configuration["Database:DefaultConnection"]!;
+
+ builder.UseWolverine(opts =>
+ WolverineConfiguration.Configure(
+ opts,
+ moduleAssemblies,
+ workerProvider,
+ dbConnectionString
+ )
+ );
+
+ builder.Services.AddResourceSetupOnStartup();
+ // Lazy breaks the SettingsService ↔ AuditingMessageBus ↔
+ // ISettingsContracts construction cycle.
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).
+ // HttpContextAccessor: EntityInterceptor returns null in non-HTTP contexts.
builder.Services.AddHttpContextAccessor();
- // EF interceptors (entities expect these when SaveChanges is called):
builder.Services.AddScoped();
- builder.Services.AddScoped();
builder.Services.AddScoped();
return builder;
diff --git a/framework/SimpleModule.Hosting/WolverineConfiguration.cs b/framework/SimpleModule.Hosting/WolverineConfiguration.cs
new file mode 100644
index 00000000..5b009b90
--- /dev/null
+++ b/framework/SimpleModule.Hosting/WolverineConfiguration.cs
@@ -0,0 +1,57 @@
+using System.Reflection;
+using SimpleModule.Core.Entities;
+using SimpleModule.Database;
+using Wolverine;
+using Wolverine.EntityFrameworkCore;
+using Wolverine.Postgresql;
+using Wolverine.Sqlite;
+using Wolverine.SqlServer;
+
+namespace SimpleModule.Hosting;
+
+internal static class WolverineConfiguration
+{
+ internal const string SchemaName = "wolverine";
+
+ ///
+ /// Wires Wolverine for in-process durable messaging backed by the configured database.
+ /// Handler discovery covers every module assembly; envelopes persist to the message
+ /// store before dispatch, local listeners are gated by the durable inbox, and entity
+ /// events flushed by SaveChangesAndFlushMessagesAsync commit atomically with
+ /// the EF write.
+ ///
+ internal static void Configure(
+ WolverineOptions opts,
+ IReadOnlyList moduleAssemblies,
+ DatabaseProvider provider,
+ string connectionString
+ )
+ {
+ foreach (var assembly in moduleAssemblies)
+ {
+ opts.Discovery.IncludeAssembly(assembly);
+ }
+
+ switch (provider)
+ {
+ case DatabaseProvider.PostgreSql:
+ opts.PersistMessagesWithPostgresql(connectionString, SchemaName);
+ break;
+ case DatabaseProvider.SqlServer:
+ opts.PersistMessagesWithSqlServer(connectionString, SchemaName);
+ break;
+ case DatabaseProvider.Sqlite:
+ opts.PersistMessagesWithSqlite(connectionString);
+ break;
+ default:
+ throw new InvalidOperationException(
+ $"Unsupported database provider for Wolverine durability: {provider}"
+ );
+ }
+
+ opts.UseEntityFrameworkCoreTransactions();
+ opts.PublishDomainEventsFromEntityFrameworkCore(x => x.Events);
+ opts.Policies.UseDurableLocalQueues();
+ opts.Policies.UseDurableInboxOnAllListeners();
+ }
+}
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsWolverineExtension.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsWolverineExtension.cs
deleted file mode 100644
index 01a4274f..00000000
--- a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsWolverineExtension.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using Wolverine;
-using Wolverine.Attributes;
-
-[assembly: WolverineModule(typeof(SimpleModule.AuditLogs.AuditLogsWolverineExtension))]
-
-namespace SimpleModule.AuditLogs;
-
-#pragma warning disable CA1812 // Instantiated by Wolverine via [WolverineModule]
-internal sealed class AuditLogsWolverineExtension : IWolverineExtension
-#pragma warning restore CA1812
-{
- public void Configure(WolverineOptions options)
- {
- options.Discovery.IncludeAssembly(typeof(AuditLogsWolverineExtension).Assembly);
- }
-}
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidatorHandler.cs
similarity index 91%
rename from modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs
rename to modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidatorHandler.cs
index b68696d5..b0dad167 100644
--- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs
+++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidatorHandler.cs
@@ -4,7 +4,7 @@
namespace SimpleModule.AuditLogs.Pipeline;
-public static class AuditConfigCacheInvalidator
+public static class AuditConfigCacheInvalidatorHandler
{
public static ValueTask Handle(SettingChangedEvent @event, IFusionCache cache)
{
diff --git a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailFailedEvent.cs b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailFailedEvent.cs
index 2d7adcd5..591c2654 100644
--- a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailFailedEvent.cs
+++ b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailFailedEvent.cs
@@ -7,4 +7,4 @@ public sealed record EmailFailedEvent(
string To,
string Subject,
string Error
-) : IEvent;
+) : DomainEvent;
diff --git a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailRetryAttemptEvent.cs b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailRetryAttemptEvent.cs
index cf36a0b2..830b0116 100644
--- a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailRetryAttemptEvent.cs
+++ b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailRetryAttemptEvent.cs
@@ -3,4 +3,4 @@
namespace SimpleModule.Email.Contracts.Events;
public sealed record EmailRetryAttemptEvent(EmailMessageId MessageId, string To, int RetryCount)
- : IEvent;
+ : DomainEvent;
diff --git a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailSentEvent.cs b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailSentEvent.cs
index eaf4ee66..cb9428a2 100644
--- a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailSentEvent.cs
+++ b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailSentEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.Email.Contracts.Events;
-public sealed record EmailSentEvent(EmailMessageId MessageId, string To, string Subject) : IEvent;
+public sealed record EmailSentEvent(EmailMessageId MessageId, string To, string Subject) : DomainEvent;
diff --git a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateCreatedEvent.cs b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateCreatedEvent.cs
index 8e9614cf..ce2e887c 100644
--- a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateCreatedEvent.cs
+++ b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateCreatedEvent.cs
@@ -3,4 +3,4 @@
namespace SimpleModule.Email.Contracts.Events;
public sealed record EmailTemplateCreatedEvent(EmailTemplateId TemplateId, string Name, string Slug)
- : IEvent;
+ : DomainEvent;
diff --git a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateDeletedEvent.cs b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateDeletedEvent.cs
index e65f92c6..0a4f75e1 100644
--- a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateDeletedEvent.cs
+++ b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateDeletedEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.Email.Contracts.Events;
-public sealed record EmailTemplateDeletedEvent(EmailTemplateId TemplateId, string Name) : IEvent;
+public sealed record EmailTemplateDeletedEvent(EmailTemplateId TemplateId, string Name) : DomainEvent;
diff --git a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateUpdatedEvent.cs b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateUpdatedEvent.cs
index 3f3eeaf3..5fcbbbde 100644
--- a/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateUpdatedEvent.cs
+++ b/modules/Email/src/SimpleModule.Email.Contracts/Events/EmailTemplateUpdatedEvent.cs
@@ -6,4 +6,4 @@ public sealed record EmailTemplateUpdatedEvent(
EmailTemplateId TemplateId,
string Name,
IReadOnlyList ChangedFields
-) : IEvent;
+) : DomainEvent;
diff --git a/modules/Email/src/SimpleModule.Email/EmailService.Templates.cs b/modules/Email/src/SimpleModule.Email/EmailService.Templates.cs
index 91b2ad14..d2e3104d 100644
--- a/modules/Email/src/SimpleModule.Email/EmailService.Templates.cs
+++ b/modules/Email/src/SimpleModule.Email/EmailService.Templates.cs
@@ -102,12 +102,12 @@ await db.EmailTemplates.FindAsync(id)
template.IsHtml = request.IsHtml;
template.DefaultReplyTo = request.DefaultReplyTo;
- await db.SaveChangesAsync();
-
- LogTemplateUpdated(logger, template.Id, template.Name);
- await bus.PublishAsync(
+ await outbox.PublishAsync(
new EmailTemplateUpdatedEvent(template.Id, template.Name, changedFields)
);
+ await outbox.SaveChangesAndFlushMessagesAsync();
+
+ LogTemplateUpdated(logger, template.Id, template.Name);
return template;
}
@@ -120,10 +120,11 @@ await db.EmailTemplates.FindAsync(id)
var templateName = template.Name;
db.EmailTemplates.Remove(template);
- await db.SaveChangesAsync();
+
+ await outbox.PublishAsync(new EmailTemplateDeletedEvent(id, templateName));
+ await outbox.SaveChangesAndFlushMessagesAsync();
LogTemplateDeleted(logger, id);
- 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 bc26a258..03dd53b3 100644
--- a/modules/Email/src/SimpleModule.Email/EmailService.cs
+++ b/modules/Email/src/SimpleModule.Email/EmailService.cs
@@ -8,6 +8,7 @@
using SimpleModule.Email.Providers;
using SimpleModule.Email.Services;
using Wolverine;
+using Wolverine.EntityFrameworkCore;
namespace SimpleModule.Email;
@@ -15,6 +16,7 @@ public partial class EmailService(
EmailDbContext db,
IEmailProvider emailProvider,
IMessageBus bus,
+ IDbContextOutbox outbox,
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 05c13b25..a2fa4b5a 100644
--- a/modules/Email/src/SimpleModule.Email/Jobs/RetryFailedEmailsJob.cs
+++ b/modules/Email/src/SimpleModule.Email/Jobs/RetryFailedEmailsJob.cs
@@ -5,6 +5,7 @@
using SimpleModule.Email.Contracts;
using SimpleModule.Email.Contracts.Events;
using Wolverine;
+using Wolverine.EntityFrameworkCore;
namespace SimpleModule.Email.Jobs;
@@ -12,7 +13,7 @@ public partial class RetryFailedEmailsJob(
EmailDbContext db,
IBackgroundJobs backgroundJobs,
IOptions options,
- IMessageBus bus,
+ IDbContextOutbox outbox,
ILogger logger
) : IModuleJob
{
@@ -37,17 +38,17 @@ CancellationToken cancellationToken
message.RetryCount++;
message.Status = EmailStatus.Retrying;
message.ErrorMessage = null;
+
+ await outbox.PublishAsync(
+ new EmailRetryAttemptEvent(message.Id, message.To, message.RetryCount)
+ );
}
- await db.SaveChangesAsync(cancellationToken);
+ await outbox.SaveChangesAndFlushMessagesAsync(cancellationToken);
foreach (var message in failedMessages)
{
LogRetryAttempt(logger, message.Id, message.To, message.RetryCount);
- await bus.PublishAsync(
- new EmailRetryAttemptEvent(message.Id, message.To, message.RetryCount)
- );
-
await backgroundJobs.EnqueueAsync(
new SendEmailJobData(message.Id),
cancellationToken
diff --git a/modules/Email/src/SimpleModule.Email/Jobs/SendEmailJob.cs b/modules/Email/src/SimpleModule.Email/Jobs/SendEmailJob.cs
index 254f35be..84ef600d 100644
--- a/modules/Email/src/SimpleModule.Email/Jobs/SendEmailJob.cs
+++ b/modules/Email/src/SimpleModule.Email/Jobs/SendEmailJob.cs
@@ -5,6 +5,7 @@
using SimpleModule.Email.Contracts.Events;
using SimpleModule.Email.Providers;
using Wolverine;
+using Wolverine.EntityFrameworkCore;
namespace SimpleModule.Email.Jobs;
@@ -12,7 +13,7 @@ public partial class SendEmailJob(
EmailDbContext db,
IEmailProvider emailProvider,
IOptions options,
- IMessageBus bus,
+ IDbContextOutbox outbox,
ILogger logger
) : IModuleJob
{
@@ -48,10 +49,13 @@ CancellationToken cancellationToken
await emailProvider.SendAsync(envelope, cancellationToken);
message.Status = EmailStatus.Sent;
message.SentAt = DateTimeOffset.UtcNow;
- await db.SaveChangesAsync(cancellationToken);
+
+ await outbox.PublishAsync(
+ new EmailSentEvent(message.Id, message.To, message.Subject)
+ );
+ await outbox.SaveChangesAndFlushMessagesAsync(cancellationToken);
LogEmailSent(logger, message.Id, message.To);
- await bus.PublishAsync(new EmailSentEvent(message.Id, message.To, message.Subject));
}
catch (Exception ex)
when (ex
@@ -65,12 +69,13 @@ or MailKit.Security.SslHandshakeException
{
message.Status = EmailStatus.Failed;
message.ErrorMessage = ex.Message;
- await db.SaveChangesAsync(cancellationToken);
- LogEmailFailed(logger, message.Id, message.To, ex);
- await bus.PublishAsync(
+ await outbox.PublishAsync(
new EmailFailedEvent(message.Id, message.To, message.Subject, ex.Message)
);
+ await outbox.SaveChangesAndFlushMessagesAsync(cancellationToken);
+
+ LogEmailFailed(logger, message.Id, message.To, ex);
}
context.ReportProgress(100);
diff --git a/modules/Email/tests/SimpleModule.Email.Tests/Unit/EmailServiceTests.cs b/modules/Email/tests/SimpleModule.Email.Tests/Unit/EmailServiceTests.cs
index 4024e045..4118a6d7 100644
--- a/modules/Email/tests/SimpleModule.Email.Tests/Unit/EmailServiceTests.cs
+++ b/modules/Email/tests/SimpleModule.Email.Tests/Unit/EmailServiceTests.cs
@@ -5,6 +5,7 @@
using SimpleModule.BackgroundJobs.Contracts;
using SimpleModule.Database;
using SimpleModule.Email.Providers;
+using SimpleModule.Tests.Shared.Fakes;
using Wolverine;
namespace SimpleModule.Email.Tests.Unit;
@@ -14,6 +15,7 @@ public sealed partial class EmailServiceTests : IDisposable
private readonly EmailDbContext _db;
private readonly EmailService _sut;
private readonly IMessageBus _bus = Substitute.For();
+ private readonly FakeDbContextOutbox _outbox;
private readonly TestBackgroundJobs _backgroundJobs = new();
public EmailServiceTests()
@@ -35,11 +37,13 @@ public EmailServiceTests()
_db.Database.EnsureCreated();
var provider = new LogEmailProvider(NullLogger.Instance);
+ _outbox = new FakeDbContextOutbox(_db);
_sut = new EmailService(
_db,
provider,
_bus,
+ _outbox,
_backgroundJobs,
NullLogger.Instance
);
diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagOverrideChangedEvent.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagOverrideChangedEvent.cs
index 72a1fd34..3ec2374f 100644
--- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagOverrideChangedEvent.cs
+++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagOverrideChangedEvent.cs
@@ -13,4 +13,4 @@ public sealed record FeatureFlagOverrideChangedEvent(
OverrideAction Action,
OverrideType OverrideType,
string OverrideValue
-) : IEvent;
+) : DomainEvent;
diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagToggledEvent.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagToggledEvent.cs
index 1753940f..f951395f 100644
--- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagToggledEvent.cs
+++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/Events/FeatureFlagToggledEvent.cs
@@ -3,4 +3,4 @@
namespace SimpleModule.FeatureFlags.Contracts.Events;
public sealed record FeatureFlagToggledEvent(string FlagName, bool IsEnabled, string UserId)
- : IEvent;
+ : DomainEvent;
diff --git a/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/Events/FileDeletedEvent.cs b/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/Events/FileDeletedEvent.cs
index 865ee61d..bd298a10 100644
--- a/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/Events/FileDeletedEvent.cs
+++ b/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/Events/FileDeletedEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.FileStorage.Contracts.Events;
-public sealed record FileDeletedEvent(FileStorageId FileId, string FileName) : IEvent;
+public sealed record FileDeletedEvent(FileStorageId FileId, string FileName) : DomainEvent;
diff --git a/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/Events/FileUploadedEvent.cs b/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/Events/FileUploadedEvent.cs
index 08cecc30..bb084eb4 100644
--- a/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/Events/FileUploadedEvent.cs
+++ b/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/Events/FileUploadedEvent.cs
@@ -7,4 +7,4 @@ public sealed record FileUploadedEvent(
string FileName,
long FileSize,
string ContentType
-) : IEvent;
+) : DomainEvent;
diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictWolverineExtension.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictWolverineExtension.cs
deleted file mode 100644
index f25ae3af..00000000
--- a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictWolverineExtension.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using Wolverine;
-using Wolverine.Attributes;
-
-[assembly: WolverineModule(typeof(SimpleModule.OpenIddict.OpenIddictWolverineExtension))]
-
-namespace SimpleModule.OpenIddict;
-
-#pragma warning disable CA1812 // Instantiated by Wolverine via [WolverineModule]
-internal sealed class OpenIddictWolverineExtension : IWolverineExtension
-#pragma warning restore CA1812
-{
- public void Configure(WolverineOptions options)
- {
- options.Discovery.IncludeAssembly(typeof(OpenIddictWolverineExtension).Assembly);
- }
-}
diff --git a/modules/Settings/src/SimpleModule.Settings.Contracts/Events/SettingChangedEvent.cs b/modules/Settings/src/SimpleModule.Settings.Contracts/Events/SettingChangedEvent.cs
index fea38408..7b04b6b9 100644
--- a/modules/Settings/src/SimpleModule.Settings.Contracts/Events/SettingChangedEvent.cs
+++ b/modules/Settings/src/SimpleModule.Settings.Contracts/Events/SettingChangedEvent.cs
@@ -8,4 +8,4 @@ public sealed record SettingChangedEvent(
string? OldValue,
string? NewValue,
SettingScope Scope
-) : IEvent;
+) : DomainEvent;
diff --git a/modules/Settings/src/SimpleModule.Settings.Contracts/Events/SettingDeletedEvent.cs b/modules/Settings/src/SimpleModule.Settings.Contracts/Events/SettingDeletedEvent.cs
index 6359935d..4bab4c50 100644
--- a/modules/Settings/src/SimpleModule.Settings.Contracts/Events/SettingDeletedEvent.cs
+++ b/modules/Settings/src/SimpleModule.Settings.Contracts/Events/SettingDeletedEvent.cs
@@ -3,4 +3,4 @@
namespace SimpleModule.Settings.Contracts.Events;
-public sealed record SettingDeletedEvent(string Key, SettingScope Scope) : IEvent;
+public sealed record SettingDeletedEvent(string Key, SettingScope Scope) : DomainEvent;
diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantCreatedEvent.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantCreatedEvent.cs
index 87994bcc..450e13c4 100644
--- a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantCreatedEvent.cs
+++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantCreatedEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.Tenants.Contracts.Events;
-public sealed record TenantCreatedEvent(TenantId TenantId, string Name, string Slug) : IEvent;
+public sealed record TenantCreatedEvent(TenantId TenantId, string Name, string Slug) : DomainEvent;
diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostAddedEvent.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostAddedEvent.cs
index 7f1f69eb..945fd389 100644
--- a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostAddedEvent.cs
+++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostAddedEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.Tenants.Contracts.Events;
-public sealed record TenantHostAddedEvent(TenantId TenantId, string HostName) : IEvent;
+public sealed record TenantHostAddedEvent(TenantId TenantId, string HostName) : DomainEvent;
diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostRemovedEvent.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostRemovedEvent.cs
index 90993d2b..430acc3e 100644
--- a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostRemovedEvent.cs
+++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostRemovedEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.Tenants.Contracts.Events;
-public sealed record TenantHostRemovedEvent(TenantId TenantId, string HostName) : IEvent;
+public sealed record TenantHostRemovedEvent(TenantId TenantId, string HostName) : DomainEvent;
diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantStatusChangedEvent.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantStatusChangedEvent.cs
index 9ba0f301..d9c57bf6 100644
--- a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantStatusChangedEvent.cs
+++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantStatusChangedEvent.cs
@@ -6,4 +6,4 @@ public sealed record TenantStatusChangedEvent(
TenantId TenantId,
TenantStatus OldStatus,
TenantStatus NewStatus
-) : IEvent;
+) : DomainEvent;
diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantUpdatedEvent.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantUpdatedEvent.cs
index 7992e73b..6a8193e5 100644
--- a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantUpdatedEvent.cs
+++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantUpdatedEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.Tenants.Contracts.Events;
-public sealed record TenantUpdatedEvent(TenantId TenantId, string Name) : IEvent;
+public sealed record TenantUpdatedEvent(TenantId TenantId, string Name) : DomainEvent;
diff --git a/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs b/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs
index 6be6bf93..09cfe0b7 100644
--- a/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs
+++ b/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs
@@ -4,12 +4,14 @@
using SimpleModule.Tenants.Contracts;
using SimpleModule.Tenants.Contracts.Events;
using Wolverine;
+using Wolverine.EntityFrameworkCore;
namespace SimpleModule.Tenants;
public sealed partial class TenantService(
TenantsDbContext db,
IMessageBus bus,
+ IDbContextOutbox outbox,
ILogger logger
) : ITenantContracts
{
@@ -101,10 +103,10 @@ public async Task UpdateTenantAsync(TenantId id, UpdateTenantRequest req
entity.ConnectionString = request.ConnectionString;
entity.ValidUpTo = request.ValidUpTo;
- await db.SaveChangesAsync();
+ await outbox.PublishAsync(new TenantUpdatedEvent(entity.Id, entity.Name));
+ await outbox.SaveChangesAndFlushMessagesAsync();
LogTenantUpdated(logger, entity.Id, entity.Name);
- await bus.PublishAsync(new TenantUpdatedEvent(entity.Id, entity.Name));
return (await GetTenantByIdAsync(id))!;
}
@@ -133,10 +135,11 @@ public async Task ChangeStatusAsync(TenantId id, TenantStatus status)
var oldStatus = entity.Status;
entity.Status = status;
- await db.SaveChangesAsync();
+
+ await outbox.PublishAsync(new TenantStatusChangedEvent(id, oldStatus, status));
+ await outbox.SaveChangesAndFlushMessagesAsync();
LogTenantStatusChanged(logger, id, oldStatus, status);
- await bus.PublishAsync(new TenantStatusChangedEvent(id, oldStatus, status));
return (await GetTenantByIdAsync(id))!;
}
@@ -152,10 +155,11 @@ public async Task AddHostAsync(TenantId tenantId, AddTenantHostReque
var hostEntity = new TenantHostEntity { TenantId = tenantId, HostName = request.HostName };
db.TenantHosts.Add(hostEntity);
- await db.SaveChangesAsync();
+
+ await outbox.PublishAsync(new TenantHostAddedEvent(tenantId, request.HostName));
+ await outbox.SaveChangesAndFlushMessagesAsync();
LogHostAdded(logger, tenantId, request.HostName);
- await bus.PublishAsync(new TenantHostAddedEvent(tenantId, request.HostName));
return MapHostToDto(hostEntity);
}
@@ -172,10 +176,11 @@ public async Task RemoveHostAsync(TenantId tenantId, TenantHostId hostId)
var hostName = host.HostName;
db.TenantHosts.Remove(host);
- await db.SaveChangesAsync();
+
+ await outbox.PublishAsync(new TenantHostRemovedEvent(tenantId, hostName));
+ await outbox.SaveChangesAndFlushMessagesAsync();
LogHostRemoved(logger, tenantId, hostName);
- await bus.PublishAsync(new TenantHostRemovedEvent(tenantId, hostName));
}
private static Tenant MapToDto(TenantEntity entity) =>
diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs
index dac46f48..88e27898 100644
--- a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs
+++ b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs
@@ -7,6 +7,7 @@
using SimpleModule.Database;
using SimpleModule.Tenants;
using SimpleModule.Tenants.Contracts;
+using SimpleModule.Tests.Shared.Fakes;
using Wolverine;
namespace Tenants.Tests.Unit;
@@ -16,6 +17,7 @@ public sealed class TenantServiceTests : IDisposable
private readonly TenantsDbContext _db;
private readonly TenantService _sut;
private readonly IMessageBus _bus = Substitute.For();
+ private readonly FakeDbContextOutbox _outbox;
public TenantServiceTests()
{
@@ -34,7 +36,8 @@ public TenantServiceTests()
_db = new TenantsDbContext(options, dbOptions);
_db.Database.OpenConnection();
_db.Database.EnsureCreated();
- _sut = new TenantService(_db, _bus, NullLogger.Instance);
+ _outbox = new FakeDbContextOutbox(_db);
+ _sut = new TenantService(_db, _bus, _outbox, NullLogger.Instance);
}
public void Dispose() => _db.Dispose();
@@ -100,10 +103,10 @@ public async Task UpdateTenantAsync_WithValidData_UpdatesTenant()
var updated = await _sut.UpdateTenantAsync(TenantId.From(1), request);
updated.Name.Should().Be("Updated Acme");
- await _bus.Received()
- .PublishAsync(
- Arg.Any(),
- Arg.Any()
+ _outbox
+ .PublishedMessages.Should()
+ .ContainSingle(m =>
+ m is SimpleModule.Tenants.Contracts.Events.TenantUpdatedEvent
);
}
@@ -134,10 +137,10 @@ public async Task ChangeStatusAsync_ChangesStatusAndPublishesEvent()
var result = await _sut.ChangeStatusAsync(TenantId.From(1), TenantStatus.Suspended);
result.Status.Should().Be(TenantStatus.Suspended);
- await _bus.Received()
- .PublishAsync(
- Arg.Any(),
- Arg.Any()
+ _outbox
+ .PublishedMessages.Should()
+ .ContainSingle(m =>
+ m is SimpleModule.Tenants.Contracts.Events.TenantStatusChangedEvent
);
}
diff --git a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserCreatedEvent.cs b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserCreatedEvent.cs
index 8cdc8023..ab45459c 100644
--- a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserCreatedEvent.cs
+++ b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserCreatedEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.Users.Contracts.Events;
-public sealed record UserCreatedEvent(UserId UserId, string Email, string DisplayName) : IEvent;
+public sealed record UserCreatedEvent(UserId UserId, string Email, string DisplayName) : DomainEvent;
diff --git a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserDeletedEvent.cs b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserDeletedEvent.cs
index 46ee4b23..ad4167a3 100644
--- a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserDeletedEvent.cs
+++ b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserDeletedEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.Users.Contracts.Events;
-public sealed record UserDeletedEvent(UserId UserId) : IEvent;
+public sealed record UserDeletedEvent(UserId UserId) : DomainEvent;
diff --git a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserRolesChangedEvent.cs b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserRolesChangedEvent.cs
index ea55877b..5e47bbee 100644
--- a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserRolesChangedEvent.cs
+++ b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserRolesChangedEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.Users.Contracts.Events;
-public sealed record UserRolesChangedEvent(UserId UserId, IReadOnlyList Roles) : IEvent;
+public sealed record UserRolesChangedEvent(UserId UserId, IReadOnlyList Roles) : DomainEvent;
diff --git a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSelfUnlockedEvent.cs b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSelfUnlockedEvent.cs
index 22a1f6a7..888f8b26 100644
--- a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSelfUnlockedEvent.cs
+++ b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSelfUnlockedEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.Users.Contracts.Events;
-public sealed record UserSelfUnlockedEvent(UserId UserId, string Email) : IEvent;
+public sealed record UserSelfUnlockedEvent(UserId UserId, string Email) : DomainEvent;
diff --git a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSignedOutEverywhereEvent.cs b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSignedOutEverywhereEvent.cs
index f24d1cf2..bf819acc 100644
--- a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSignedOutEverywhereEvent.cs
+++ b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSignedOutEverywhereEvent.cs
@@ -8,4 +8,4 @@ namespace SimpleModule.Users.Contracts.Events;
/// outstanding access/refresh tokens, since bearer holders bypass the cookie-side
/// SecurityStampValidator.
///
-public sealed record UserSignedOutEverywhereEvent(UserId UserId) : IEvent;
+public sealed record UserSignedOutEverywhereEvent(UserId UserId) : DomainEvent;
diff --git a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserUpdatedEvent.cs b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserUpdatedEvent.cs
index 361b0cf1..583a9e49 100644
--- a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserUpdatedEvent.cs
+++ b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserUpdatedEvent.cs
@@ -2,4 +2,4 @@
namespace SimpleModule.Users.Contracts.Events;
-public sealed record UserUpdatedEvent(UserId UserId, string Email, string DisplayName) : IEvent;
+public sealed record UserUpdatedEvent(UserId UserId, string Email, string DisplayName) : DomainEvent;
diff --git a/template/SimpleModule.Worker/Program.cs b/template/SimpleModule.Worker/Program.cs
index efa9c7ef..190f8952 100644
--- a/template/SimpleModule.Worker/Program.cs
+++ b/template/SimpleModule.Worker/Program.cs
@@ -10,7 +10,7 @@
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddLocalStorage(builder.Configuration);
-builder.AddSimpleModuleWorker();
+builder.AddSimpleModuleWorker(ModuleExtensions.ModuleAssemblies);
// Source-generated: registers all module services and lifecycle hosting.
builder.Services.AddModules(builder.Configuration);
diff --git a/tests/SimpleModule.Core.Tests/Infrastructure/EventDurabilityE2ETests.cs b/tests/SimpleModule.Core.Tests/Infrastructure/EventDurabilityE2ETests.cs
new file mode 100644
index 00000000..63b108a9
--- /dev/null
+++ b/tests/SimpleModule.Core.Tests/Infrastructure/EventDurabilityE2ETests.cs
@@ -0,0 +1,160 @@
+using System.Net.Http.Json;
+using FluentAssertions;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SimpleModule.Core.Settings;
+using SimpleModule.Settings.Contracts.Events;
+using SimpleModule.Tests.Shared.Fixtures;
+using Wolverine;
+using Wolverine.Tracking;
+using Xunit;
+
+namespace SimpleModule.Core.Tests.Infrastructure;
+
+///
+/// End-to-end runtime verification of the durable event system. Exercises the real
+/// host wired by against the real
+/// Wolverine durability tables and asserts on the actual SQLite state. This is the
+/// proof that publishing an IEvent writes an envelope to disk and routes
+/// through the durable inbox / outbox in production-shaped configuration.
+///
+[Collection(TestCollections.Integration)]
+public sealed class EventDurabilityE2ETests(SimpleModuleWebApplicationFactory factory)
+{
+ [Fact]
+ public async Task PublishAsync_Routes_Event_Through_Durable_Wolverine_Pipeline()
+ {
+ // Boot the factory by hitting any endpoint — ensures Wolverine schema is up.
+ using var _ = factory.CreateClient();
+
+ using var scope = factory.Services.CreateScope();
+ var bus = scope.ServiceProvider.GetRequiredService();
+
+ // Wolverine's tracked-session helper waits for the envelope to be produced AND
+ // processed end-to-end — no flaky sleep, no polling.
+ var session = await factory
+ .Services.GetRequiredService()
+ .TrackActivity()
+ .Timeout(TimeSpan.FromSeconds(10))
+ .ExecuteAndWaitAsync(
+ (Func)(
+ async _ =>
+ {
+ // Key starts with "auditlogs." so AuditConfigCacheInvalidatorHandler
+ // (a real handler in the AuditLogs module) executes.
+ await bus.PublishAsync(
+ new SettingChangedEvent(
+ Key: "auditlogs.capture.domain",
+ OldValue: "true",
+ NewValue: "false",
+ Scope: SettingScope.System
+ )
+ );
+ }
+ )
+ );
+
+ // (1) The event reached Wolverine's pipeline.
+ var allMessages = session
+ .AllRecordsInOrder()
+ .Where(r => r.Envelope?.Message is not null)
+ .Select(r => r.Envelope!.Message!)
+ .ToList();
+ allMessages
+ .Should()
+ .Contain(m => m is SettingChangedEvent, "the event must flow through Wolverine");
+
+ // (2) The Wolverine durability tables exist on disk — proves we are running
+ // against the real durable message store, not an in-memory transport.
+ (await TableExistsAsync("wolverine_outgoing_envelopes"))
+ .Should()
+ .BeTrue();
+ (await TableExistsAsync("wolverine_incoming_envelopes")).Should().BeTrue();
+ (await TableExistsAsync("wolverine_dead_letters")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task AuditConfigCacheInvalidatorHandler_Runs_For_SettingChangedEvent()
+ {
+ // Proves an actual handler in the AuditLogs module executes on the durable
+ // pipeline (not just that the message is enqueued). Seeds the audit-config
+ // cache, publishes a SettingChangedEvent for an "auditlogs.*" key, and
+ // asserts the cache entry is gone — the only side effect the handler has.
+ using var _ = factory.CreateClient();
+ using var scope = factory.Services.CreateScope();
+ var cache =
+ scope.ServiceProvider.GetRequiredService();
+ var bus = scope.ServiceProvider.GetRequiredService();
+
+ await cache.SetAsync("auditlogs:request-config", "sentinel");
+ (await cache.TryGetAsync("auditlogs:request-config")).HasValue.Should().BeTrue();
+
+ await factory
+ .Services.GetRequiredService()
+ .TrackActivity()
+ .Timeout(TimeSpan.FromSeconds(10))
+ .ExecuteAndWaitAsync(
+ (Func)(
+ async _ =>
+ {
+ await bus.PublishAsync(
+ new SettingChangedEvent(
+ Key: "auditlogs.capture.domain",
+ OldValue: "true",
+ NewValue: "false",
+ Scope: SettingScope.System
+ )
+ );
+ }
+ )
+ );
+
+ (await cache.TryGetAsync("auditlogs:request-config"))
+ .HasValue.Should()
+ .BeFalse(
+ "AuditConfigCacheInvalidatorHandler must drain the audit-config cache after the event"
+ );
+ }
+
+ [Fact]
+ public async Task PublishedEvent_Survives_The_Durable_Pipeline_End_To_End()
+ {
+ // Belt-and-suspenders companion to the test above: a real HTTP request that
+ // hits a service which internally calls bus.PublishAsync. Ensures the full
+ // ASP.NET → service → Wolverine path works under the durable wiring.
+ var client = factory.CreateAuthenticatedClient();
+ var response = await client.PutAsJsonAsync(
+ "/api/settings",
+ new
+ {
+ key = "verify.api.event",
+ value = "\"hello\"",
+ scope = 0,
+ }
+ );
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+
+ // The request would have failed if the durable outbox couldn't accept the
+ // envelope (Wolverine throws on persist failures during PublishAsync).
+ // Surviving with a 2xx response is proof that the outbox round-trip works.
+ (await TableExistsAsync("wolverine_outgoing_envelopes"))
+ .Should()
+ .BeTrue();
+ }
+
+ private static async Task TableExistsAsync(string table)
+ {
+ using var connection = new SqliteConnection(
+ $"Data Source={SimpleModuleWebApplicationFactory.WolverineDbPath}"
+ );
+ await connection.OpenAsync();
+
+ using var command = connection.CreateCommand();
+ command.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = $name";
+ command.Parameters.AddWithValue("$name", table);
+ var result = await command.ExecuteScalarAsync();
+ return result is not null;
+ }
+}
diff --git a/tests/SimpleModule.Core.Tests/Infrastructure/WebApplicationFactoryTests.cs b/tests/SimpleModule.Core.Tests/Infrastructure/WebApplicationFactoryTests.cs
index c5011874..7cfdbe62 100644
--- a/tests/SimpleModule.Core.Tests/Infrastructure/WebApplicationFactoryTests.cs
+++ b/tests/SimpleModule.Core.Tests/Infrastructure/WebApplicationFactoryTests.cs
@@ -237,7 +237,7 @@ public static TheoryData AllContractTypes
}
}
- private sealed record NoopEvent : IEvent;
+ private sealed record NoopEvent : DomainEvent;
// ── Authenticated client ────────────────────────────────────────
diff --git a/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj b/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj
index 8edd1efa..c836cf7d 100644
--- a/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj
+++ b/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj
@@ -12,6 +12,7 @@
+
diff --git a/tests/SimpleModule.Database.Tests/DomainEventInterceptorTests.cs b/tests/SimpleModule.Database.Tests/DomainEventInterceptorTests.cs
deleted file mode 100644
index 62889c46..00000000
--- a/tests/SimpleModule.Database.Tests/DomainEventInterceptorTests.cs
+++ /dev/null
@@ -1,228 +0,0 @@
-using FluentAssertions;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Diagnostics;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Options;
-using SimpleModule.Core.Entities;
-using SimpleModule.Core.Events;
-using SimpleModule.Database.Interceptors;
-using Wolverine;
-
-namespace SimpleModule.Database.Tests;
-
-public sealed class DomainEventInterceptorTests
-{
- [Fact]
- public async Task Domain_Events_Dispatched_After_SaveChanges()
- {
- var bus = new RecordingMessageBus();
- await using var fixture = CreateFixture(bus);
-
- var entity = new AggregateRootTestEntity { Name = "Test" };
- entity.TriggerSomethingHappened();
-
- fixture.Context.AggregateRoots.Add(entity);
- await fixture.Context.SaveChangesAsync();
-
- bus.PublishedMessages.Should().HaveCount(1);
- bus.PublishedMessages[0].Should().BeOfType();
- }
-
- [Fact]
- public async Task Domain_Events_Cleared_After_Dispatch()
- {
- var bus = new RecordingMessageBus();
- await using var fixture = CreateFixture(bus);
-
- var entity = new AggregateRootTestEntity { Name = "Test" };
- entity.TriggerSomethingHappened();
-
- fixture.Context.AggregateRoots.Add(entity);
- await fixture.Context.SaveChangesAsync();
-
- entity.GetDomainEvents().Should().BeEmpty();
- }
-
- [Fact]
- public async Task No_Events_Dispatched_When_No_Domain_Events()
- {
- 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();
-
- bus.PublishedMessages.Should().BeEmpty();
- }
-
- [Fact]
- public async Task Multiple_Events_From_Multiple_Entities_All_Dispatched()
- {
- var bus = new RecordingMessageBus();
- await using var fixture = CreateFixture(bus);
-
- var entity1 = new AggregateRootTestEntity { Name = "First" };
- entity1.TriggerSomethingHappened();
- entity1.TriggerSomethingHappened();
-
- var entity2 = new AggregateRootTestEntity { Name = "Second" };
- entity2.TriggerSomethingHappened();
-
- fixture.Context.AggregateRoots.AddRange(entity1, entity2);
- await fixture.Context.SaveChangesAsync();
-
- bus.PublishedMessages.Should().HaveCount(3);
- }
-
- private static TestFixture CreateFixture(IMessageBus bus)
- {
- var config = new ConfigurationBuilder()
- .AddInMemoryCollection(
- new Dictionary
- {
- ["Database:DefaultConnection"] = "Data Source=:memory:",
- }
- )
- .Build();
-
- var services = new ServiceCollection();
- services.AddSingleton(bus);
- services.AddScoped();
- services.AddModuleDbContext(config, "DomainEventTest");
-
- var provider = services.BuildServiceProvider();
- var context = provider.GetRequiredService();
- context.Database.OpenConnection();
- context.Database.EnsureCreated();
-
- return new TestFixture(provider, context);
- }
-
- private sealed class TestFixture(ServiceProvider provider, DomainEventTestDbContext context)
- : IAsyncDisposable
- {
- public DomainEventTestDbContext Context => context;
-
- public async ValueTask DisposeAsync()
- {
- await context.DisposeAsync();
- await provider.DisposeAsync();
- }
- }
-}
-
-public sealed record SomethingHappenedEvent : IEvent;
-
-public class AggregateRootTestEntity : IHasDomainEvents
-{
- private readonly List _events = [];
-
- public int Id { get; set; }
- public string Name { get; set; } = string.Empty;
-
- public IReadOnlyList GetDomainEvents() => _events.AsReadOnly();
-
- public void ClearDomainEvents() => _events.Clear();
-
- public void TriggerSomethingHappened() => _events.Add(new SomethingHappenedEvent());
-}
-
-///
-/// Recording IMessageBus that captures PublishAsync calls for assertion.
-/// Other IMessageBus methods throw — the interceptor only uses PublishAsync.
-///
-public sealed class RecordingMessageBus : IMessageBus
-{
- public List
diff --git a/tests/SimpleModule.Database.Tests/WolverineAssemblyDiscoveryTests.cs b/tests/SimpleModule.Database.Tests/WolverineAssemblyDiscoveryTests.cs
new file mode 100644
index 00000000..8709bfaa
--- /dev/null
+++ b/tests/SimpleModule.Database.Tests/WolverineAssemblyDiscoveryTests.cs
@@ -0,0 +1,63 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SimpleModule.Core.Events;
+using Wolverine;
+using Wolverine.Tracking;
+
+namespace SimpleModule.Database.Tests;
+
+///
+/// Locks in the contract the host's UseWolverine(...) wiring relies on: handlers
+/// in any module assembly registered via options.Discovery.IncludeAssembly are
+/// discovered and invoked. If Wolverine ever changes this discovery contract, the
+/// auto-include flow in SimpleModuleHostExtensions / SimpleModuleWorkerExtensions
+/// would silently stop picking up handlers — this test catches that.
+///
+public sealed class WolverineAssemblyDiscoveryTests
+{
+ [Fact]
+ public async Task Handler_In_Included_Assembly_Is_Invoked()
+ {
+ DiscoveryTestHandler.Reset();
+
+ var builder = Host.CreateApplicationBuilder();
+ builder.UseWolverine(opts =>
+ {
+ opts.Discovery.IncludeAssembly(typeof(WolverineAssemblyDiscoveryTests).Assembly);
+ });
+
+ using var host = builder.Build();
+ await host.StartAsync();
+
+ var bus = host.Services.GetRequiredService();
+
+ // Wait for the handler to actually execute before asserting — PublishAsync
+ // is fire-and-forget; on slower runners (Linux CI) the assertion otherwise
+ // races against the in-memory dispatcher.
+ await host.TrackActivity()
+ .Timeout(TimeSpan.FromSeconds(10))
+ .ExecuteAndWaitAsync(
+ (Func)(
+ async _ =>
+ {
+ await bus.PublishAsync(new DiscoveryTestEvent("hello"));
+ }
+ )
+ );
+
+ DiscoveryTestHandler.LastPayload.Should().Be("hello");
+
+ await host.StopAsync();
+ }
+}
+
+public sealed record DiscoveryTestEvent(string Payload) : DomainEvent;
+
+public static class DiscoveryTestHandler
+{
+ public static string? LastPayload { get; private set; }
+
+ public static void Reset() => LastPayload = null;
+
+ public static void Handle(DiscoveryTestEvent @event) => LastPayload = @event.Payload;
+}
diff --git a/tests/SimpleModule.Database.Tests/WolverineDurabilitySmokeTests.cs b/tests/SimpleModule.Database.Tests/WolverineDurabilitySmokeTests.cs
new file mode 100644
index 00000000..3ef8051b
--- /dev/null
+++ b/tests/SimpleModule.Database.Tests/WolverineDurabilitySmokeTests.cs
@@ -0,0 +1,169 @@
+using JasperFx;
+using JasperFx.Resources;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SimpleModule.Core.Events;
+using Wolverine;
+using Wolverine.Sqlite;
+
+namespace SimpleModule.Database.Tests;
+
+///
+/// Smoke-tests that the durable Wolverine wiring used by the framework actually
+/// persists envelopes to the configured database before dispatch. If Wolverine
+/// ever regressed to in-memory transport these assertions would fail.
+///
+public sealed class WolverineDurabilitySmokeTests : IDisposable
+{
+ private readonly string _dbPath = Path.Combine(
+ Path.GetTempPath(),
+ $"wolverine-smoke-{Guid.NewGuid():N}.db"
+ );
+
+ private string ConnectionString => $"Data Source={_dbPath}";
+
+ public void Dispose()
+ {
+ try
+ {
+ File.Delete(_dbPath);
+ }
+#pragma warning disable CA1031
+ catch
+ {
+ // Best-effort cleanup; SQLite may still hold the file briefly.
+ }
+#pragma warning restore CA1031
+ GC.SuppressFinalize(this);
+ }
+
+ [Fact]
+ public async Task Wolverine_Creates_Outbox_Schema_On_Startup()
+ {
+ using var host = await BuildHostAsync();
+
+ var tables = await ReadTableNamesAsync();
+ tables.Should().Contain(t => t.StartsWith("wolverine_", StringComparison.Ordinal));
+
+ await host.StopAsync();
+ }
+
+ [Fact]
+ public async Task PublishAsync_Persists_Envelope_To_Outbox_Before_Dispatch()
+ {
+ SlowEventHandler.Reset();
+
+ using var host = await BuildHostAsync();
+ var bus = host.Services.GetRequiredService();
+
+ // Publish without awaiting the handler — the durable outbox guarantees the
+ // envelope row exists after PublishAsync completes, even before the handler
+ // finishes.
+ await bus.PublishAsync(new SlowEvent());
+
+ var outgoingCount = await CountWolverineEnvelopesAsync();
+ outgoingCount
+ .Should()
+ .BeGreaterThan(
+ 0,
+ "publishing must write the envelope to a Wolverine durability table"
+ );
+
+ // Let the handler drain so the host shuts down cleanly.
+ SlowEventHandler.Completion.SetResult();
+ await SlowEventHandler.InvokedTask.Task;
+
+ await host.StopAsync();
+ }
+
+ private async Task BuildHostAsync()
+ {
+ var builder = Host.CreateApplicationBuilder();
+
+ builder.UseWolverine(opts =>
+ {
+ opts.Discovery.IncludeAssembly(typeof(WolverineDurabilitySmokeTests).Assembly);
+ opts.PersistMessagesWithSqlite(ConnectionString);
+ opts.Policies.UseDurableLocalQueues();
+ });
+
+ builder.Services.AddResourceSetupOnStartup();
+
+ var host = builder.Build();
+ await host.StartAsync();
+ return host;
+ }
+
+ private Task> ReadTableNamesAsync() =>
+ ReadTableNamesAsync(ConnectionString);
+
+ private Task CountWolverineEnvelopesAsync() =>
+ CountWolverineEnvelopesAsync(ConnectionString);
+
+ private static async Task> ReadTableNamesAsync(string connectionString)
+ {
+ using var connection = new SqliteConnection(connectionString);
+ await connection.OpenAsync();
+
+ using var command = connection.CreateCommand();
+ command.CommandText =
+ "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name";
+
+ var names = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ names.Add(reader.GetString(0));
+ }
+ return names;
+ }
+
+ private static async Task CountWolverineEnvelopesAsync(string connectionString)
+ {
+ var tables = await ReadTableNamesAsync(connectionString);
+ var envelopeTables = tables
+ .Where(t => t.StartsWith("wolverine_", StringComparison.Ordinal))
+ .Where(t => t.Contains("envelope", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ long total = 0;
+ using var connection = new SqliteConnection(connectionString);
+ await connection.OpenAsync();
+
+ foreach (var table in envelopeTables)
+ {
+ using var command = connection.CreateCommand();
+ // Table names sourced from sqlite_master (not user input); safe to interpolate.
+#pragma warning disable CA2100
+ command.CommandText = $"SELECT COUNT(*) FROM \"{table}\"";
+#pragma warning restore CA2100
+ var result = await command.ExecuteScalarAsync();
+ if (result is long count)
+ {
+ total += count;
+ }
+ }
+ return total;
+ }
+}
+
+public sealed record SlowEvent : DomainEvent;
+
+public static class SlowEventHandler
+{
+ public static TaskCompletionSource Completion { get; private set; } = new();
+ public static TaskCompletionSource InvokedTask { get; private set; } = new();
+
+ public static void Reset()
+ {
+ Completion = new TaskCompletionSource();
+ InvokedTask = new TaskCompletionSource();
+ }
+
+ public static async Task Handle(SlowEvent _)
+ {
+ InvokedTask.TrySetResult();
+ await Completion.Task;
+ }
+}
diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeDbContextOutbox.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeDbContextOutbox.cs
new file mode 100644
index 00000000..a8860961
--- /dev/null
+++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeDbContextOutbox.cs
@@ -0,0 +1,108 @@
+using Microsoft.EntityFrameworkCore;
+using Wolverine;
+using Wolverine.EntityFrameworkCore;
+
+namespace SimpleModule.Tests.Shared.Fakes;
+
+///
+/// Minimal in-memory for unit-testing services that
+/// were migrated to the transactional outbox pattern. Records every PublishAsync /
+/// SendAsync call for assertion, and flushes by calling SaveChangesAsync on the
+/// wrapped DbContext so DB state matches what the production outbox would commit.
+/// Outbox envelope persistence is intentionally not simulated — that is verified
+/// by integration tests against a real Wolverine host.
+///
+public sealed class FakeDbContextOutbox(TDbContext context)
+ : IDbContextOutbox
+ where TDbContext : DbContext
+{
+ public TDbContext DbContext { get; } = context;
+
+ public List