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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -443,3 +443,4 @@ template/SimpleModule.Host/storage/

# Temporary refactor baseline — not committed
baseline/
.claude/settings.local.json
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<!-- Messaging / Event Bus -->
<PackageVersion Include="WolverineFx" Version="5.31.0" />
<PackageVersion Include="WolverineFx.EntityFrameworkCore" Version="5.31.0" />
<PackageVersion Include="WolverineFx.Sqlite" Version="5.31.0" />
<PackageVersion Include="WolverineFx.Postgresql" Version="5.31.0" />
<PackageVersion Include="WolverineFx.SqlServer" Version="5.31.0" />
<PackageVersion Include="Scrutor" Version="7.0.0" />
<!-- Email -->
<PackageVersion Include="MailKit" Version="4.16.0" />
Expand Down
14 changes: 9 additions & 5 deletions docs/CONSTITUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,15 @@ This is cosmetic organization -- all modules share one connection.
### Events

- Cross-module notifications use Wolverine's `IMessageBus.PublishAsync<T>()`
- 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<TDbContext>`, 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<IHasDomainEvents>(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<IMessageBus>` 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

Expand Down
11 changes: 4 additions & 7 deletions framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,12 @@ namespace SimpleModule.Core.Entities;

/// <summary>
/// Aggregate root with audit tracking, soft delete, versioning, and domain events.
/// Domain events are automatically dispatched via Wolverine's <c>IMessageBus</c> after SaveChanges.
/// Domain events added via <see cref="AddDomainEvent"/> are flushed to Wolverine's
/// durable outbox during <c>SaveChangesAsync</c>, atomic with the EF transaction.
/// </summary>
public abstract class AuditableAggregateRoot<TId> : FullAuditableEntity<TId>, IHasDomainEvents
{
private readonly List<IEvent> _domainEvents = [];
public List<IEvent> Events { get; } = [];

public IReadOnlyList<IEvent> GetDomainEvents() => _domainEvents.AsReadOnly();

public void ClearDomainEvents() => _domainEvents.Clear();

protected void AddDomainEvent(IEvent domainEvent) => _domainEvents.Add(domainEvent);
protected void AddDomainEvent(IEvent domainEvent) => Events.Add(domainEvent);
}
9 changes: 5 additions & 4 deletions framework/SimpleModule.Core/Entities/IHasDomainEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Entities implementing this interface can raise domain events that are automatically
/// dispatched via Wolverine's <c>IMessageBus</c> after a successful SaveChanges.
/// Entities implementing this interface have their <see cref="Events"/> list scraped
/// by Wolverine's <c>PublishDomainEventsFromEntityFrameworkCore</c> integration during
/// <c>SaveChangesAsync</c> — events are written to the outbox in the same transaction
/// as the EF business write, and the list is cleared after scrape.
/// </summary>
public interface IHasDomainEvents
{
IReadOnlyList<IEvent> GetDomainEvents();
void ClearDomainEvents();
List<IEvent> Events { get; }
}
25 changes: 22 additions & 3 deletions framework/SimpleModule.Core/Events/IEvent.cs
Original file line number Diff line number Diff line change
@@ -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
/// <summary>
/// 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
/// <see cref="DomainEvent"/> rather than implementing this directly.
/// </summary>
public interface IEvent
{
Guid EventId { get; }
DateTimeOffset OccurredAt { get; }
}

/// <summary>
/// Base record for domain events. Derive from this to get a unique <see cref="IEvent.EventId"/>
/// and an <see cref="IEvent.OccurredAt"/> stamp without having to repeat the boilerplate
/// on every event record.
/// </summary>
public abstract record DomainEvent : IEvent
{
public Guid EventId { get; init; } = Guid.CreateVersion7();
public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow;
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite" />
<PackageReference Include="WolverineFx.EntityFrameworkCore" />
<ProjectReference Include="..\SimpleModule.Core\SimpleModule.Core.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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(" });");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
);
}

sb.AppendLine();
sb.AppendLine(
" /// <summary>Assemblies for every discovered module. Used by the host to register"
);
sb.AppendLine(
" /// Wolverine handler discovery for every module without per-module boilerplate.</summary>"
);
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)"
Expand Down
4 changes: 4 additions & 0 deletions framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="WolverineFx.EntityFrameworkCore" />
<PackageReference Include="WolverineFx.Sqlite" />
<PackageReference Include="WolverineFx.Postgresql" />
<PackageReference Include="WolverineFx.SqlServer" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SimpleModule.Core\SimpleModule.Core.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -45,7 +45,7 @@ private static void BridgeAspireConnectionString(ConfigurationManager configurat
}
}

private static DatabaseProvider ValidateDatabaseConfiguration(
internal static DatabaseProvider ValidateDatabaseConfiguration(
ConfigurationManager configuration
)
{
Expand Down
24 changes: 18 additions & 6 deletions framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<IMessageBus> lets services break factory-lambda cycles
// (e.g. SettingsService ↔ AuditingMessageBus via ISettingsContracts).
builder.Services.AddScoped(sp => new Lazy<IMessageBus>(() =>
Expand All @@ -89,9 +103,7 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, HttpContextCurrentUser>();

// Entity framework interceptors for automatic entity field population
builder.Services.AddScoped<ISaveChangesInterceptor, EntityInterceptor>();
builder.Services.AddScoped<ISaveChangesInterceptor, DomainEventInterceptor>();
builder.Services.AddScoped<ISaveChangesInterceptor, EntityChangeInterceptor>();

// Authentication is configured by modules via their ConfigureServices
Expand Down
9 changes: 9 additions & 0 deletions framework/SimpleModule.Hosting/SimpleModuleOptions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using SimpleModule.Core;
using SimpleModule.Database;
Expand All @@ -8,6 +9,14 @@ public class SimpleModuleOptions
{
private readonly List<Action<IServiceCollection>> _moduleOptionsActions = [];

/// <summary>
/// Module assemblies to scan for Wolverine handlers. Set by the source-generated
/// <c>AddSimpleModule()</c> from <c>ModuleExtensions.ModuleAssemblies</c>; not
/// intended for user code.
/// </summary>
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
public IReadOnlyList<Assembly> ModuleAssemblies { get; set; } = [];

public bool EnableSwagger { get; set; } = true;

public bool EnableHealthChecks { get; set; } = true;
Expand Down
Loading
Loading