Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
8b1ea01
Design spec for composable AddRCommon across modules
jasonmwebb-lv May 16, 2026
756e503
Address spec-review feedback on modular bootstrapper design
jasonmwebb-lv May 16, 2026
ce9352e
Resolve residual finalize-trigger ambiguity in spec
jasonmwebb-lv May 16, 2026
ae86cbe
Formalize bootstrapping domain spec at canonical docs/specs/ location
jasonmwebb-lv May 16, 2026
3b293d6
Implementation plan for modular bootstrapper
jasonmwebb-lv May 16, 2026
8e3828a
Address plan-review feedback
jasonmwebb-lv May 16, 2026
680aa9e
Fix two cosmetic items in plan (4 obsolete overloads, ConcreteType)
jasonmwebb-lv May 16, 2026
58fabef
feat(bootstrapping): cache IRCommonBuilder on IServiceCollection for …
jasonmwebb-lv May 16, 2026
b2d7143
feat(bootstrapping): add GetOrAddBuilder helper for sub-builder caching
jasonmwebb-lv May 16, 2026
fcae101
feat(bootstrapping): same-type idempotent, different-type throw for s…
jasonmwebb-lv May 16, 2026
e2023c8
feat(persistence): allow idempotent re-registration of identical data…
jasonmwebb-lv May 16, 2026
2e92638
docs(persistence): update DataStoreFactoryOptions.Register XML to mat…
jasonmwebb-lv May 16, 2026
2c7968e
feat(event-handling): descriptor-scan dedup for AddProducer<T>
jasonmwebb-lv May 16, 2026
df78968
feat(bootstrapping): route core verbs through GetOrAddBuilder cache
jasonmwebb-lv May 16, 2026
3f9cbd9
fix(tests): add GetOrAddBuilder stub to hand-rolled IRCommonBuilder f…
jasonmwebb-lv May 16, 2026
7a77df2
feat(json): singleton-style WithJsonSerialization; same type idempote…
jasonmwebb-lv May 16, 2026
b3ee30f
feat(mediator): route WithMediator through GetOrAddBuilder cache
jasonmwebb-lv May 16, 2026
8ce7b02
feat(mediatr): route WithEventHandling through GetOrAddBuilder cache
jasonmwebb-lv May 16, 2026
3a0124e
feat(masstransit): route WithEventHandling through GetOrAddBuilder cache
jasonmwebb-lv May 16, 2026
56b7ddf
feat(caching): route WithMemoryCaching and WithDistributedCaching thr…
jasonmwebb-lv May 16, 2026
89702f2
feat(cqrs): route WithCQRS through GetOrAddBuilder cache
jasonmwebb-lv May 16, 2026
dc93df7
feat(validation): route WithValidation through GetOrAddBuilder cache
jasonmwebb-lv May 16, 2026
eff0e28
feat(blobs): route WithBlobStorage through GetOrAddBuilder cache
jasonmwebb-lv May 16, 2026
de1b722
feat(multitenancy): route WithMultiTenancy through GetOrAddBuilder cache
jasonmwebb-lv May 16, 2026
282f3e4
feat(stateless): idempotent registrations for WithStatelessStateMachine
jasonmwebb-lv May 16, 2026
0de78ad
feat(masstransit-sm): idempotent registrations for WithMassTransitSta…
jasonmwebb-lv May 16, 2026
b810f0b
feat(email): singleton-style With*EmailServices with conflict detection
jasonmwebb-lv May 16, 2026
c744a7c
feat(security): idempotent registrations for principal-accessor verbs
jasonmwebb-lv May 16, 2026
f664d91
feat(bootstrapping): finalize hosted service emits warning on soft du…
jasonmwebb-lv May 16, 2026
f07421c
fix(tests): add GetBootstrapDiagnostics stub to hand-rolled IRCommonB…
jasonmwebb-lv May 16, 2026
9e0c1dd
test(efcore): multi-module integration covering merge, idempotent, an…
jasonmwebb-lv May 16, 2026
2c2f3bb
test(mediatr): multi-module integration for event producers
jasonmwebb-lv May 16, 2026
759781b
test(email): cover singleton-style conflict detection
jasonmwebb-lv May 16, 2026
1f5027b
test(bootstrapping): rename misleading test (called twice, not once)
jasonmwebb-lv May 16, 2026
c18af54
feat(examples): modular composition example with three independent mo…
jasonmwebb-lv May 17, 2026
bf491e2
docs(bootstrapping): modular composition page, critical fixes, change…
jasonmwebb-lv May 17, 2026
9c4d579
docs(bootstrapping): getting-started + high-priority subsystem update…
jasonmwebb-lv May 17, 2026
f006013
docs(bootstrapping): remaining subsystem and architecture-guide updat…
jasonmwebb-lv May 17, 2026
c231d91
docs(bootstrapping): use .mdx extensions for cross-links to satisfy s…
jasonmwebb-lv May 17, 2026
c042ee1
chore(website): rebuild static site for modular composition docs
jasonmwebb-lv May 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Examples.Bootstrapping.MultiModule.Domain.Inventory;
using Microsoft.EntityFrameworkCore;
using RCommon.Persistence.EFCore;

namespace Examples.Bootstrapping.MultiModule.Data;

/// <summary>
/// DbContext for the Inventory module. A separate type from <see cref="OrderingDbContext"/> so that
/// both can coexist under distinct data-store names without conflicting.
/// </summary>
public class InventoryDbContext : RCommonDbContext
{
public InventoryDbContext(DbContextOptions<InventoryDbContext> options) : base(options)
{
}

public DbSet<InventoryItem> Items => Set<InventoryItem>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Examples.Bootstrapping.MultiModule.Domain.Orders;
using Microsoft.EntityFrameworkCore;
using RCommon.Persistence.EFCore;

namespace Examples.Bootstrapping.MultiModule.Data;

/// <summary>
/// DbContext for the Ordering module. Inherits directly from <see cref="RCommonDbContext"/> so that
/// <c>DataStoreFactoryOptions.Register&lt;RCommonDbContext, OrderingDbContext&gt;("Ordering")</c> wires up correctly.
/// </summary>
public class OrderingDbContext : RCommonDbContext
{
public OrderingDbContext(DbContextOptions<OrderingDbContext> options) : base(options)
{
}

public DbSet<Order> Orders => Set<Order>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Examples.Bootstrapping.MultiModule.Domain.Inventory;

/// <summary>
/// Minimal InventoryItem aggregate used by the Inventory module.
/// </summary>
public class InventoryItem
{
public Guid Id { get; set; }

public string Sku { get; set; } = string.Empty;

public int Quantity { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Examples.Bootstrapping.MultiModule.Domain.Orders;

/// <summary>
/// Minimal Order aggregate used by the Ordering module. Persistence configuration is
/// intentionally lightweight; the example focuses on bootstrapping semantics, not domain depth.
/// </summary>
public class Order
{
public Guid Id { get; set; }

public string CustomerName { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\Src\RCommon.Core\RCommon.Core.csproj" />
<ProjectReference Include="..\..\..\Src\RCommon.Persistence\RCommon.Persistence.csproj" />
<ProjectReference Include="..\..\..\Src\RCommon.EfCore\RCommon.EFCore.csproj" />
<ProjectReference Include="..\..\..\Src\RCommon.Caching\RCommon.Caching.csproj" />
<ProjectReference Include="..\..\..\Src\RCommon.MemoryCache\RCommon.MemoryCache.csproj" />
</ItemGroup>

<ItemGroup>
<!-- Matches the EF Core 10.0.2 versions pinned in Src/RCommon.EfCore for net10.0. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.Extensions.DependencyInjection;

namespace Examples.Bootstrapping.MultiModule.Modules;

/// <summary>
/// Minimal contract a module implements to configure services in a multi-module composition.
/// Each module independently calls <c>services.AddRCommon()</c>; the bootstrapper guarantees
/// idempotent and merge-able registration semantics across modules.
/// </summary>
public interface IServiceModule
{
void Configure(IServiceCollection services);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Examples.Bootstrapping.MultiModule.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RCommon;
using RCommon.Caching;
using RCommon.MemoryCache;

namespace Examples.Bootstrapping.MultiModule.Modules;

/// <summary>
/// The Inventory module configures its own DbContext under a distinct data-store name,
/// re-declares the same guid generator (idempotent no-op), and adds in-memory caching.
/// </summary>
public class InventoryModule : IServiceModule
{
public void Configure(IServiceCollection services)
{
services.AddRCommon()
.WithSimpleGuidGenerator() // Same impl as Ordering -> idempotent no-op.
.WithPersistence<EFCorePerisistenceBuilder>(ef =>
ef.AddDbContext<InventoryDbContext>(
"Inventory",
o => o.UseInMemoryDatabase("inventory")))
.WithMemoryCaching<InMemoryCachingBuilder>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Examples.Bootstrapping.MultiModule.Producers;
using Microsoft.Extensions.DependencyInjection;
using RCommon;
using RCommon.EventHandling;

namespace Examples.Bootstrapping.MultiModule.Modules;

/// <summary>
/// The Notifications module re-registers <see cref="AuditProducer"/> (already registered by
/// <see cref="OrderingModule"/>). The bootstrapper detects the duplicate and keeps a single
/// descriptor, so <c>IServiceProvider.GetServices&lt;IEventProducer&gt;()</c> resolves it once.
/// </summary>
public class NotificationsModule : IServiceModule
{
public void Configure(IServiceCollection services)
{
services.AddRCommon()
.WithEventHandling<InMemoryEventBusBuilder>(eh => eh.AddProducer<AuditProducer>());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Examples.Bootstrapping.MultiModule.Data;
using Examples.Bootstrapping.MultiModule.Producers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RCommon;
using RCommon.EventHandling;

namespace Examples.Bootstrapping.MultiModule.Modules;

/// <summary>
/// The Ordering module configures persistence for its own DbContext and a guid generator,
/// and registers an <see cref="AuditProducer"/> for cross-cutting auditing.
/// </summary>
public class OrderingModule : IServiceModule
{
public void Configure(IServiceCollection services)
{
services.AddRCommon()
.WithSimpleGuidGenerator() // Singleton verb: idempotent across modules when impl matches.
.WithPersistence<EFCorePerisistenceBuilder>(ef =>
ef.AddDbContext<OrderingDbContext>(
"Ordering",
o => o.UseInMemoryDatabase("ordering")))
.WithEventHandling<InMemoryEventBusBuilder>(eh => eh.AddProducer<AuditProducer>());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using RCommon.EventHandling.Producers;
using RCommon.Models.Events;

namespace Examples.Bootstrapping.MultiModule.Producers;

/// <summary>
/// No-op <see cref="IEventProducer"/> used to demonstrate cross-module producer dedup.
/// Two modules register this same producer type; the bootstrapper merges them into a single descriptor.
/// </summary>
public class AuditProducer : IEventProducer
{
public Task ProduceEventAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
where TEvent : ISerializableEvent
{
// No-op for example purposes.
return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Examples.Bootstrapping.MultiModule.Modules;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RCommon;
using RCommon.EventHandling.Producers;

var host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
IServiceModule[] modules =
{
new OrderingModule(),
new InventoryModule(),
new NotificationsModule(),
};

Console.WriteLine($"IsRCommonInitialized before any module: {services.IsRCommonInitialized()}");

foreach (var module in modules)
{
module.Configure(services);
Console.WriteLine($" {module.GetType().Name} configured; IsRCommonInitialized={services.IsRCommonInitialized()}");
}
})
.Build();

await host.StartAsync();

var sp = host.Services;
var producerCount = sp.GetServices<IEventProducer>().Count();
Console.WriteLine($"\nDistinct IEventProducer instances resolved: {producerCount}");
Console.WriteLine("Expected: 1 (AuditProducer was registered by Ordering and Notifications but dedup keeps it to one).\n");

var builder = sp.GetRequiredService<IRCommonBuilder>();
var diagnostics = builder.GetBootstrapDiagnostics();
if (!string.IsNullOrEmpty(diagnostics))
{
Console.WriteLine("Bootstrap diagnostics report:");
Console.WriteLine(diagnostics);
}
else
{
Console.WriteLine("Bootstrap diagnostics: no soft duplicates detected.");
}

await host.StopAsync();
18 changes: 18 additions & 0 deletions Examples/Examples.sln
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RCommon.TestBase", "..\Test
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.Messaging.SubscriptionIsolation", "Messaging\Examples.Messaging.SubscriptionIsolation\Examples.Messaging.SubscriptionIsolation.csproj", "{DFF5FC1F-D20F-4BFE-BD58-A0B4F1E414CB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Bootstrapping", "Bootstrapping", "{173CBA2D-76A2-457F-947A-8CBE38D63121}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Examples.Bootstrapping.MultiModule", "Bootstrapping\Examples.Bootstrapping.MultiModule\Examples.Bootstrapping.MultiModule.csproj", "{60782FC7-AB13-44D9-A4F1-E8C602697C3E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -719,6 +723,18 @@ Global
{DFF5FC1F-D20F-4BFE-BD58-A0B4F1E414CB}.Release|x64.Build.0 = Release|Any CPU
{DFF5FC1F-D20F-4BFE-BD58-A0B4F1E414CB}.Release|x86.ActiveCfg = Release|Any CPU
{DFF5FC1F-D20F-4BFE-BD58-A0B4F1E414CB}.Release|x86.Build.0 = Release|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Debug|x64.ActiveCfg = Debug|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Debug|x64.Build.0 = Debug|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Debug|x86.ActiveCfg = Debug|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Debug|x86.Build.0 = Debug|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Release|Any CPU.Build.0 = Release|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Release|x64.ActiveCfg = Release|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Release|x64.Build.0 = Release|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Release|x86.ActiveCfg = Release|Any CPU
{60782FC7-AB13-44D9-A4F1-E8C602697C3E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -768,6 +784,8 @@ Global
{43AD4063-F157-994B-5A6D-92B9ABCA126E} = {3234C3BB-1632-4684-838E-9D6D382D4D4D}
{740F4266-7ADA-7893-C575-B8C531C029EC} = {3234C3BB-1632-4684-838E-9D6D382D4D4D}
{DFF5FC1F-D20F-4BFE-BD58-A0B4F1E414CB} = {35AE0870-0A6D-4F27-B534-B8DCDFD11A36}
{173CBA2D-76A2-457F-947A-8CBE38D63121} = {3234C3BB-1632-4684-838E-9D6D382D4D4D}
{60782FC7-AB13-44D9-A4F1-E8C602697C3E} = {173CBA2D-76A2-457F-947A-8CBE38D63121}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0B0CD26D-8067-4667-863E-6B0EE7EDAA42}
Expand Down
10 changes: 6 additions & 4 deletions Src/RCommon.ApplicationServices/CqrsBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public static class CqrsBuilderExtensions
/// <param name="builder">The RCommon builder.</param>
/// <returns>The <paramref name="builder"/> for further chaining.</returns>
public static IRCommonBuilder WithCQRS<T>(this IRCommonBuilder builder)
where T : ICqrsBuilder
where T : class, ICqrsBuilder
{

return WithCQRS<T>(builder, x => { });
Expand All @@ -64,11 +64,13 @@ public static IRCommonBuilder WithCQRS<T>(this IRCommonBuilder builder)
/// <param name="actions">A delegate to configure the CQRS builder (e.g., register handlers).</param>
/// <returns>The <paramref name="builder"/> for further chaining.</returns>
public static IRCommonBuilder WithCQRS<T>(this IRCommonBuilder builder, Action<T> actions)
where T : ICqrsBuilder
where T : class, ICqrsBuilder
{

// Instantiate the CQRS builder implementation, which registers core bus services in its constructor
var cqrsBuilder = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!;
// Instantiate the CQRS builder implementation, which registers core bus services in its constructor.
// Routed through GetOrAddBuilder so repeated WithCQRS<T> calls reuse the cached sub-builder.
var cqrsBuilder = builder.GetOrAddBuilder<T>(
() => (T)Activator.CreateInstance(typeof(T), new object[] { builder })!);
actions(cqrsBuilder);
return builder;
}
Expand Down
12 changes: 7 additions & 5 deletions Src/RCommon.ApplicationServices/ValidationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public static class ValidationBuilderExtensions
/// <param name="builder">The RCommon builder.</param>
/// <returns>The <paramref name="builder"/> for further chaining.</returns>
public static IRCommonBuilder WithValidation<T>(this IRCommonBuilder builder)
where T : IValidationBuilder
where T : class, IValidationBuilder
{
return WithValidation<T>(builder, x => { });
}
Expand All @@ -36,14 +36,16 @@ public static IRCommonBuilder WithValidation<T>(this IRCommonBuilder builder)
/// <param name="actions">A delegate to configure the validation builder (e.g., register validation providers).</param>
/// <returns>The <paramref name="builder"/> for further chaining.</returns>
public static IRCommonBuilder WithValidation<T>(this IRCommonBuilder builder, Action<T> actions)
where T : IValidationBuilder
where T : class, IValidationBuilder
{

builder.Services.AddScoped<IValidationService, ValidationService>();

// Instantiate the validation builder implementation, which may register provider-specific services
var mediatorConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!;
actions(mediatorConfig);
// Instantiate the validation builder implementation, which may register provider-specific services.
// Routed through GetOrAddBuilder so repeated WithValidation<T> calls reuse the cached sub-builder.
var validationConfig = builder.GetOrAddBuilder<T>(
() => (T)Activator.CreateInstance(typeof(T), new object[] { builder })!);
actions(validationConfig);
return builder;
}

Expand Down
11 changes: 7 additions & 4 deletions Src/RCommon.Blobs/BlobStorageBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public static class BlobStorageBuilderExtensions
/// Registers a blob storage provider with default configuration.
/// </summary>
public static IRCommonBuilder WithBlobStorage<T>(this IRCommonBuilder builder)
where T : IBlobStorageBuilder
where T : class, IBlobStorageBuilder
{
return WithBlobStorage<T>(builder, x => { });
}
Expand All @@ -22,14 +22,17 @@ public static IRCommonBuilder WithBlobStorage<T>(this IRCommonBuilder builder)
/// Can be called multiple times to register multiple providers (e.g. Azure + S3).
/// </summary>
public static IRCommonBuilder WithBlobStorage<T>(this IRCommonBuilder builder, Action<T> actions)
where T : IBlobStorageBuilder
where T : class, IBlobStorageBuilder
{
Guard.IsNotNull(actions, nameof(actions));

builder.Services.TryAddSingleton<IBlobStoreFactory, BlobStoreFactory>();

var blobConfig = (T)(Activator.CreateInstance(typeof(T), new object[] { builder })
?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}."));
// Routed through GetOrAddBuilder so repeated WithBlobStorage<T> calls for the same provider type
// reuse the cached sub-builder. Different T (e.g. Azure vs S3) still get distinct sub-builders.
var blobConfig = builder.GetOrAddBuilder<T>(
() => (T)(Activator.CreateInstance(typeof(T), new object[] { builder })
?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}.")));
actions(blobConfig);
return builder;
}
Expand Down
Loading
Loading