diff --git a/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Data/InventoryDbContext.cs b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Data/InventoryDbContext.cs
new file mode 100644
index 00000000..baa8506f
--- /dev/null
+++ b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Data/InventoryDbContext.cs
@@ -0,0 +1,18 @@
+using Examples.Bootstrapping.MultiModule.Domain.Inventory;
+using Microsoft.EntityFrameworkCore;
+using RCommon.Persistence.EFCore;
+
+namespace Examples.Bootstrapping.MultiModule.Data;
+
+///
+/// DbContext for the Inventory module. A separate type from so that
+/// both can coexist under distinct data-store names without conflicting.
+///
+public class InventoryDbContext : RCommonDbContext
+{
+ public InventoryDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ public DbSet Items => Set();
+}
diff --git a/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Data/OrderingDbContext.cs b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Data/OrderingDbContext.cs
new file mode 100644
index 00000000..1928e8b1
--- /dev/null
+++ b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Data/OrderingDbContext.cs
@@ -0,0 +1,18 @@
+using Examples.Bootstrapping.MultiModule.Domain.Orders;
+using Microsoft.EntityFrameworkCore;
+using RCommon.Persistence.EFCore;
+
+namespace Examples.Bootstrapping.MultiModule.Data;
+
+///
+/// DbContext for the Ordering module. Inherits directly from so that
+/// DataStoreFactoryOptions.Register<RCommonDbContext, OrderingDbContext>("Ordering") wires up correctly.
+///
+public class OrderingDbContext : RCommonDbContext
+{
+ public OrderingDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ public DbSet Orders => Set();
+}
diff --git a/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Domain/Inventory/InventoryItem.cs b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Domain/Inventory/InventoryItem.cs
new file mode 100644
index 00000000..8f76869c
--- /dev/null
+++ b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Domain/Inventory/InventoryItem.cs
@@ -0,0 +1,13 @@
+namespace Examples.Bootstrapping.MultiModule.Domain.Inventory;
+
+///
+/// Minimal InventoryItem aggregate used by the Inventory module.
+///
+public class InventoryItem
+{
+ public Guid Id { get; set; }
+
+ public string Sku { get; set; } = string.Empty;
+
+ public int Quantity { get; set; }
+}
diff --git a/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Domain/Orders/Order.cs b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Domain/Orders/Order.cs
new file mode 100644
index 00000000..d34ec7ce
--- /dev/null
+++ b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Domain/Orders/Order.cs
@@ -0,0 +1,12 @@
+namespace Examples.Bootstrapping.MultiModule.Domain.Orders;
+
+///
+/// Minimal Order aggregate used by the Ordering module. Persistence configuration is
+/// intentionally lightweight; the example focuses on bootstrapping semantics, not domain depth.
+///
+public class Order
+{
+ public Guid Id { get; set; }
+
+ public string CustomerName { get; set; } = string.Empty;
+}
diff --git a/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Examples.Bootstrapping.MultiModule.csproj b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Examples.Bootstrapping.MultiModule.csproj
new file mode 100644
index 00000000..fac1f5e9
--- /dev/null
+++ b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Examples.Bootstrapping.MultiModule.csproj
@@ -0,0 +1,23 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/IServiceModule.cs b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/IServiceModule.cs
new file mode 100644
index 00000000..be2416cb
--- /dev/null
+++ b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/IServiceModule.cs
@@ -0,0 +1,13 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Examples.Bootstrapping.MultiModule.Modules;
+
+///
+/// Minimal contract a module implements to configure services in a multi-module composition.
+/// Each module independently calls services.AddRCommon(); the bootstrapper guarantees
+/// idempotent and merge-able registration semantics across modules.
+///
+public interface IServiceModule
+{
+ void Configure(IServiceCollection services);
+}
diff --git a/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/InventoryModule.cs b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/InventoryModule.cs
new file mode 100644
index 00000000..8976f056
--- /dev/null
+++ b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/InventoryModule.cs
@@ -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;
+
+///
+/// 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.
+///
+public class InventoryModule : IServiceModule
+{
+ public void Configure(IServiceCollection services)
+ {
+ services.AddRCommon()
+ .WithSimpleGuidGenerator() // Same impl as Ordering -> idempotent no-op.
+ .WithPersistence(ef =>
+ ef.AddDbContext(
+ "Inventory",
+ o => o.UseInMemoryDatabase("inventory")))
+ .WithMemoryCaching();
+ }
+}
diff --git a/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/NotificationsModule.cs b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/NotificationsModule.cs
new file mode 100644
index 00000000..12860302
--- /dev/null
+++ b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/NotificationsModule.cs
@@ -0,0 +1,20 @@
+using Examples.Bootstrapping.MultiModule.Producers;
+using Microsoft.Extensions.DependencyInjection;
+using RCommon;
+using RCommon.EventHandling;
+
+namespace Examples.Bootstrapping.MultiModule.Modules;
+
+///
+/// The Notifications module re-registers (already registered by
+/// ). The bootstrapper detects the duplicate and keeps a single
+/// descriptor, so IServiceProvider.GetServices<IEventProducer>() resolves it once.
+///
+public class NotificationsModule : IServiceModule
+{
+ public void Configure(IServiceCollection services)
+ {
+ services.AddRCommon()
+ .WithEventHandling(eh => eh.AddProducer());
+ }
+}
diff --git a/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/OrderingModule.cs b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/OrderingModule.cs
new file mode 100644
index 00000000..669554aa
--- /dev/null
+++ b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Modules/OrderingModule.cs
@@ -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;
+
+///
+/// The Ordering module configures persistence for its own DbContext and a guid generator,
+/// and registers an for cross-cutting auditing.
+///
+public class OrderingModule : IServiceModule
+{
+ public void Configure(IServiceCollection services)
+ {
+ services.AddRCommon()
+ .WithSimpleGuidGenerator() // Singleton verb: idempotent across modules when impl matches.
+ .WithPersistence(ef =>
+ ef.AddDbContext(
+ "Ordering",
+ o => o.UseInMemoryDatabase("ordering")))
+ .WithEventHandling(eh => eh.AddProducer());
+ }
+}
diff --git a/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Producers/AuditProducer.cs b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Producers/AuditProducer.cs
new file mode 100644
index 00000000..56c1d4f4
--- /dev/null
+++ b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Producers/AuditProducer.cs
@@ -0,0 +1,18 @@
+using RCommon.EventHandling.Producers;
+using RCommon.Models.Events;
+
+namespace Examples.Bootstrapping.MultiModule.Producers;
+
+///
+/// No-op used to demonstrate cross-module producer dedup.
+/// Two modules register this same producer type; the bootstrapper merges them into a single descriptor.
+///
+public class AuditProducer : IEventProducer
+{
+ public Task ProduceEventAsync(TEvent @event, CancellationToken cancellationToken = default)
+ where TEvent : ISerializableEvent
+ {
+ // No-op for example purposes.
+ return Task.CompletedTask;
+ }
+}
diff --git a/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Program.cs b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Program.cs
new file mode 100644
index 00000000..7ad275df
--- /dev/null
+++ b/Examples/Bootstrapping/Examples.Bootstrapping.MultiModule/Program.cs
@@ -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().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();
+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();
diff --git a/Examples/Examples.sln b/Examples/Examples.sln
index 13afa34a..ca5f162b 100644
--- a/Examples/Examples.sln
+++ b/Examples/Examples.sln
@@ -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
@@ -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
@@ -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}
diff --git a/Src/RCommon.ApplicationServices/CqrsBuilderExtensions.cs b/Src/RCommon.ApplicationServices/CqrsBuilderExtensions.cs
index 33e5e0c2..e6ab021a 100644
--- a/Src/RCommon.ApplicationServices/CqrsBuilderExtensions.cs
+++ b/Src/RCommon.ApplicationServices/CqrsBuilderExtensions.cs
@@ -50,7 +50,7 @@ public static class CqrsBuilderExtensions
/// The RCommon builder.
/// The for further chaining.
public static IRCommonBuilder WithCQRS(this IRCommonBuilder builder)
- where T : ICqrsBuilder
+ where T : class, ICqrsBuilder
{
return WithCQRS(builder, x => { });
@@ -64,11 +64,13 @@ public static IRCommonBuilder WithCQRS(this IRCommonBuilder builder)
/// A delegate to configure the CQRS builder (e.g., register handlers).
/// The for further chaining.
public static IRCommonBuilder WithCQRS(this IRCommonBuilder builder, Action 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 calls reuse the cached sub-builder.
+ var cqrsBuilder = builder.GetOrAddBuilder(
+ () => (T)Activator.CreateInstance(typeof(T), new object[] { builder })!);
actions(cqrsBuilder);
return builder;
}
diff --git a/Src/RCommon.ApplicationServices/ValidationBuilderExtensions.cs b/Src/RCommon.ApplicationServices/ValidationBuilderExtensions.cs
index df50f837..b72e241e 100644
--- a/Src/RCommon.ApplicationServices/ValidationBuilderExtensions.cs
+++ b/Src/RCommon.ApplicationServices/ValidationBuilderExtensions.cs
@@ -23,7 +23,7 @@ public static class ValidationBuilderExtensions
/// The RCommon builder.
/// The for further chaining.
public static IRCommonBuilder WithValidation(this IRCommonBuilder builder)
- where T : IValidationBuilder
+ where T : class, IValidationBuilder
{
return WithValidation(builder, x => { });
}
@@ -36,14 +36,16 @@ public static IRCommonBuilder WithValidation(this IRCommonBuilder builder)
/// A delegate to configure the validation builder (e.g., register validation providers).
/// The for further chaining.
public static IRCommonBuilder WithValidation(this IRCommonBuilder builder, Action actions)
- where T : IValidationBuilder
+ where T : class, IValidationBuilder
{
builder.Services.AddScoped();
- // 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 calls reuse the cached sub-builder.
+ var validationConfig = builder.GetOrAddBuilder(
+ () => (T)Activator.CreateInstance(typeof(T), new object[] { builder })!);
+ actions(validationConfig);
return builder;
}
diff --git a/Src/RCommon.Blobs/BlobStorageBuilderExtensions.cs b/Src/RCommon.Blobs/BlobStorageBuilderExtensions.cs
index 1bfe2f0e..bf95c300 100644
--- a/Src/RCommon.Blobs/BlobStorageBuilderExtensions.cs
+++ b/Src/RCommon.Blobs/BlobStorageBuilderExtensions.cs
@@ -12,7 +12,7 @@ public static class BlobStorageBuilderExtensions
/// Registers a blob storage provider with default configuration.
///
public static IRCommonBuilder WithBlobStorage(this IRCommonBuilder builder)
- where T : IBlobStorageBuilder
+ where T : class, IBlobStorageBuilder
{
return WithBlobStorage(builder, x => { });
}
@@ -22,14 +22,17 @@ public static IRCommonBuilder WithBlobStorage(this IRCommonBuilder builder)
/// Can be called multiple times to register multiple providers (e.g. Azure + S3).
///
public static IRCommonBuilder WithBlobStorage(this IRCommonBuilder builder, Action actions)
- where T : IBlobStorageBuilder
+ where T : class, IBlobStorageBuilder
{
Guard.IsNotNull(actions, nameof(actions));
builder.Services.TryAddSingleton();
- 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 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)(Activator.CreateInstance(typeof(T), new object[] { builder })
+ ?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}.")));
actions(blobConfig);
return builder;
}
diff --git a/Src/RCommon.Caching/CachingBuilderExtensions.cs b/Src/RCommon.Caching/CachingBuilderExtensions.cs
index ca5a4637..c7a2d606 100644
--- a/Src/RCommon.Caching/CachingBuilderExtensions.cs
+++ b/Src/RCommon.Caching/CachingBuilderExtensions.cs
@@ -20,7 +20,7 @@ public static class CachingBuilderExtensions
/// The RCommon builder instance.
/// The same for chaining.
public static IRCommonBuilder WithMemoryCaching(this IRCommonBuilder builder)
- where T : IMemoryCachingBuilder
+ where T : class, IMemoryCachingBuilder
{
return WithMemoryCaching(builder, x => { });
}
@@ -37,12 +37,14 @@ public static IRCommonBuilder WithMemoryCaching(this IRCommonBuilder builder)
/// and must have a constructor that accepts an .
///
public static IRCommonBuilder WithMemoryCaching(this IRCommonBuilder builder, Action actions)
- where T : IMemoryCachingBuilder
+ where T : class, IMemoryCachingBuilder
{
Guard.IsNotNull(actions, nameof(actions));
- // Create the builder via reflection, passing the IRCommonBuilder to its constructor
- var cachingConfig = (T)(Activator.CreateInstance(typeof(T), new object[] { builder })
- ?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}."));
+ // Create the builder via reflection, passing the IRCommonBuilder to its constructor.
+ // Routed through GetOrAddBuilder so repeated WithMemoryCaching calls reuse the cached sub-builder.
+ var cachingConfig = builder.GetOrAddBuilder(
+ () => (T)(Activator.CreateInstance(typeof(T), new object[] { builder })
+ ?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}.")));
actions(cachingConfig);
return builder;
}
@@ -54,7 +56,7 @@ public static IRCommonBuilder WithMemoryCaching(this IRCommonBuilder builder,
/// The RCommon builder instance.
/// The same for chaining.
public static IRCommonBuilder WithDistributedCaching(this IRCommonBuilder builder)
- where T : IDistributedCachingBuilder
+ where T : class, IDistributedCachingBuilder
{
return WithDistributedCaching(builder, x => { });
}
@@ -71,12 +73,14 @@ public static IRCommonBuilder WithDistributedCaching(this IRCommonBuilder bui
/// and must have a constructor that accepts an .
///
public static IRCommonBuilder WithDistributedCaching(this IRCommonBuilder builder, Action actions)
- where T : IDistributedCachingBuilder
+ where T : class, IDistributedCachingBuilder
{
Guard.IsNotNull(actions, nameof(actions));
- // Create the builder via reflection, passing the IRCommonBuilder to its constructor
- var cachingConfig = (T)(Activator.CreateInstance(typeof(T), new object[] { builder })
- ?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}."));
+ // Create the builder via reflection, passing the IRCommonBuilder to its constructor.
+ // Routed through GetOrAddBuilder so repeated WithDistributedCaching calls reuse the cached sub-builder.
+ var cachingConfig = builder.GetOrAddBuilder(
+ () => (T)(Activator.CreateInstance(typeof(T), new object[] { builder })
+ ?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}.")));
actions(cachingConfig);
return builder;
}
diff --git a/Src/RCommon.Core/Bootstrapping/RCommonBuilderInternals.cs b/Src/RCommon.Core/Bootstrapping/RCommonBuilderInternals.cs
new file mode 100644
index 00000000..78d92f88
--- /dev/null
+++ b/Src/RCommon.Core/Bootstrapping/RCommonBuilderInternals.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace RCommon.Bootstrapping
+{
+ ///
+ /// Internal helpers for singleton-style WithX verbs that live outside RCommon.Core.
+ /// Not intended for use by application code.
+ ///
+ public static class RCommonBuilderInternals
+ {
+ ///
+ /// Returns the cached sub-builder concrete type that implements , or null if none.
+ ///
+ public static Type? FindCachedImplementationOf(IRCommonBuilder builder)
+ {
+ return (builder as RCommonBuilder)?.TryGetCachedSubBuilderImplementing(typeof(TInterface));
+ }
+ }
+}
diff --git a/Src/RCommon.Core/EventHandling/EventHandlingBuilderExtensions.cs b/Src/RCommon.Core/EventHandling/EventHandlingBuilderExtensions.cs
index 382899de..b34c0bc5 100644
--- a/Src/RCommon.Core/EventHandling/EventHandlingBuilderExtensions.cs
+++ b/Src/RCommon.Core/EventHandling/EventHandlingBuilderExtensions.cs
@@ -26,7 +26,7 @@ public static class EventHandlingBuilderExtensions
/// The RCommon builder instance.
/// The for method chaining.
public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder)
- where T : IEventHandlingBuilder
+ where T : class, IEventHandlingBuilder
{
return WithEventHandling(builder, x => { });
}
@@ -39,10 +39,10 @@ public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder)
/// An action to configure the event handling builder.
/// The for method chaining.
public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, Action actions)
- where T : IEventHandlingBuilder
+ where T : class, IEventHandlingBuilder
{
- // Event Handling Configurations
- var eventHandlingConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!;
+ var eventHandlingConfig = builder.GetOrAddBuilder(
+ () => (T)Activator.CreateInstance(typeof(T), new object[] { builder })!);
actions(eventHandlingConfig);
return builder;
}
@@ -56,7 +56,12 @@ public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder,
public static void AddProducer(this IEventHandlingBuilder builder)
where T : class, IEventProducer
{
- builder.Services.AddSingleton();
+ var alreadyRegistered = builder.Services.Any(d =>
+ d.ServiceType == typeof(IEventProducer) && d.ImplementationType == typeof(T));
+ if (!alreadyRegistered)
+ {
+ builder.Services.AddSingleton();
+ }
// Track which producer type is associated with this builder type
var subscriptionManager = builder.Services.GetSubscriptionManager();
@@ -73,10 +78,17 @@ public static void AddProducer(this IEventHandlingBuilder builder)
public static void AddProducer(this IEventHandlingBuilder builder, Func getProducer)
where T : class, IEventProducer
{
- builder.Services.AddSingleton(getProducer);
+ // Factory descriptors set ImplementationFactory, not ImplementationType, so descriptor
+ // scanning won't reliably dedupe. Gate on the subscription manager's set-based tracking.
+ var subscriptionManager = builder.Services.GetSubscriptionManager();
+ var alreadyTracked = subscriptionManager?.HasProducerForBuilder(builder.GetType(), typeof(T)) ?? false;
+
+ if (!alreadyTracked)
+ {
+ builder.Services.AddSingleton(getProducer);
+ }
// Track which producer type is associated with this builder type
- var subscriptionManager = builder.Services.GetSubscriptionManager();
subscriptionManager?.AddProducerForBuilder(builder.GetType(), typeof(T));
}
diff --git a/Src/RCommon.Core/EventHandling/Producers/EventSubscriptionManager.cs b/Src/RCommon.Core/EventHandling/Producers/EventSubscriptionManager.cs
index b17b972e..7e6a694a 100644
--- a/Src/RCommon.Core/EventHandling/Producers/EventSubscriptionManager.cs
+++ b/Src/RCommon.Core/EventHandling/Producers/EventSubscriptionManager.cs
@@ -33,6 +33,21 @@ public void AddProducerForBuilder(Type builderType, Type producerType)
}
}
+ ///
+ /// Returns true if the given producer type has already been registered through the given builder type.
+ ///
+ public bool HasProducerForBuilder(Type builderType, Type producerType)
+ {
+ if (_builderProducerMap.TryGetValue(builderType, out var producers))
+ {
+ lock (producers)
+ {
+ return producers.Contains(producerType);
+ }
+ }
+ return false;
+ }
+
///
/// Records that an event type should be handled by all producers registered on a specific builder.
/// Called during AddSubscriber configuration.
diff --git a/Src/RCommon.Core/Extensions/ServiceCollectionExtensions.cs b/Src/RCommon.Core/Extensions/ServiceCollectionExtensions.cs
index 7eea61d0..50a39503 100644
--- a/Src/RCommon.Core/Extensions/ServiceCollectionExtensions.cs
+++ b/Src/RCommon.Core/Extensions/ServiceCollectionExtensions.cs
@@ -23,11 +23,35 @@ public static class ServiceCollectionExtensions
/// RCommon configuration interface for additional chaining.
public static IRCommonBuilder AddRCommon(this IServiceCollection services)
{
+ var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IRCommonBuilder));
+ if (existing?.ImplementationInstance is IRCommonBuilder cached)
+ {
+ return cached;
+ }
+
var config = new RCommonBuilder(services);
- config.Configure();
+ services.AddSingleton(config);
+ config.Configure(); // No-op in the base class (just returns Services); preserved for consistency with the existing API shape and in case a subclass overrides it.
+
+ // Register finalize diagnostics hosted service exactly once (first AddRCommon call only).
+ // The cached-return branch above ensures subsequent AddRCommon calls do not re-register.
+ services.AddSingleton(sp =>
+ new RCommonBootstrapDiagnosticsHostedService(
+ services,
+ sp.GetRequiredService(),
+ sp.GetService()));
+
return config;
}
+ ///
+ /// Returns true if has been invoked against this collection.
+ ///
+ public static bool IsRCommonInitialized(this IServiceCollection services)
+ {
+ return services.Any(d => d.ServiceType == typeof(IRCommonBuilder));
+ }
+
///
/// Registers as an singleton if the type implements the interface.
///
diff --git a/Src/RCommon.Core/IRCommonBuilder.cs b/Src/RCommon.Core/IRCommonBuilder.cs
index 99c13a09..9bd697ff 100644
--- a/Src/RCommon.Core/IRCommonBuilder.cs
+++ b/Src/RCommon.Core/IRCommonBuilder.cs
@@ -53,5 +53,22 @@ public interface IRCommonBuilder
IRCommonBuilder WithCommonFactory()
where TService : class
where TImplementation : class, TService;
+
+ ///
+ /// Returns the cached sub-builder for if one exists,
+ /// otherwise invokes , caches the result, and returns it.
+ ///
+ /// Concrete sub-builder type (e.g., EFCorePerisistenceBuilder).
+ /// Parameterless factory invoked exactly once per
+ /// per . Callers close over whichever constructor argument the sub-builder
+ /// requires — typically builder.Services or builder itself.
+ TSubBuilder GetOrAddBuilder(Func factory)
+ where TSubBuilder : class;
+
+ ///
+ /// Returns soft-duplicate diagnostic output stashed at finalize when no
+ /// was available. Empty string if no duplicates were detected.
+ ///
+ string GetBootstrapDiagnostics();
}
}
diff --git a/Src/RCommon.Core/RCommonBootstrapDiagnosticsHostedService.cs b/Src/RCommon.Core/RCommonBootstrapDiagnosticsHostedService.cs
new file mode 100644
index 00000000..a7a31ca2
--- /dev/null
+++ b/Src/RCommon.Core/RCommonBootstrapDiagnosticsHostedService.cs
@@ -0,0 +1,55 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace RCommon
+{
+ ///
+ /// Runs the duplicate-registration scanner once at host startup and emits a single
+ /// warning (or stashes the message on the builder) if soft duplicates are detected.
+ ///
+ internal sealed class RCommonBootstrapDiagnosticsHostedService : IHostedService
+ {
+ private readonly IServiceCollection _services;
+ private readonly IRCommonBuilder _builder;
+ private readonly ILoggerFactory? _loggerFactory;
+
+ public RCommonBootstrapDiagnosticsHostedService(
+ IServiceCollection services,
+ IRCommonBuilder builder,
+ ILoggerFactory? loggerFactory = null)
+ {
+ _services = services;
+ _builder = builder;
+ _loggerFactory = loggerFactory;
+ }
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ if (_builder is not RCommonBuilder rb || !rb.TrySetDiagnosticsRun())
+ {
+ return Task.CompletedTask;
+ }
+
+ var report = _services.GeneratePossibleDuplicatesServiceDescriptorsString();
+ if (string.IsNullOrWhiteSpace(report))
+ {
+ return Task.CompletedTask;
+ }
+
+ rb.StashDiagnostics(report);
+
+ if (_loggerFactory is not null)
+ {
+ var logger = _loggerFactory.CreateLogger();
+ logger.LogWarning("RCommon bootstrap detected duplicate service registrations:\n{Report}", report);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+}
diff --git a/Src/RCommon.Core/RCommonBuilder.cs b/Src/RCommon.Core/RCommonBuilder.cs
index 14e57c17..c2a739eb 100644
--- a/Src/RCommon.Core/RCommonBuilder.cs
+++ b/Src/RCommon.Core/RCommonBuilder.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
using RCommon.EventHandling;
@@ -15,8 +16,11 @@ public class RCommonBuilder : IRCommonBuilder
///
public IServiceCollection Services { get; }
- private bool _guidConfigured = false;
- private bool _dateTimeConfigured = false;
+ private SingletonRegistration _guidRegistration;
+ private SingletonRegistration _dateTimeRegistration;
+ private readonly Dictionary _subBuilderCache = new();
+ private bool _diagnosticsRun;
+ private string _bootstrapDiagnostics = string.Empty;
///
/// Initializes a new instance of and registers core framework services
@@ -44,37 +48,66 @@ public RCommonBuilder(IServiceCollection services)
}
///
- /// Thrown if a GUID generator has already been configured.
+ /// Thrown if a different GUID generator implementation has already been configured.
public IRCommonBuilder WithSequentialGuidGenerator(Action actions)
{
- Guard.Against(this._guidConfigured,
- "Guid Generator has already been configured once. You cannot configure multiple times");
+ if (_guidRegistration.Configured)
+ {
+ if (_guidRegistration.ImplementationType == typeof(SequentialGuidGenerator))
+ {
+ // Same impl re-registered: idempotent; just append the options delegate
+ this.Services.Configure(actions);
+ return this;
+ }
+ throw new RCommonBuilderException(
+ $"IGuidGenerator already configured as '{_guidRegistration.ImplementationType?.FullName}'; " +
+ $"cannot reconfigure as '{typeof(SequentialGuidGenerator).FullName}'. " +
+ "To configure multiple modules consistently, ensure all modules agree on the same IGuidGenerator implementation, " +
+ "or designate a single composition root that performs this registration.");
+ }
+
this.Services.Configure(actions);
this.Services.AddTransient();
- this._guidConfigured = true;
+ _guidRegistration = new SingletonRegistration { Configured = true, ImplementationType = typeof(SequentialGuidGenerator) };
return this;
}
///
- /// Thrown if a GUID generator has already been configured.
+ /// Thrown if a different GUID generator implementation has already been configured.
public IRCommonBuilder WithSimpleGuidGenerator()
{
- Guard.Against(this._guidConfigured,
- "Guid Generator has already been configured once. You cannot configure multiple times");
+ if (_guidRegistration.Configured)
+ {
+ if (_guidRegistration.ImplementationType == typeof(SimpleGuidGenerator))
+ {
+ return this;
+ }
+ throw new RCommonBuilderException(
+ $"IGuidGenerator already configured as '{_guidRegistration.ImplementationType?.FullName}'; " +
+ $"cannot reconfigure as '{typeof(SimpleGuidGenerator).FullName}'. " +
+ "To configure multiple modules consistently, ensure all modules agree on the same IGuidGenerator implementation, " +
+ "or designate a single composition root that performs this registration.");
+ }
+
this.Services.AddScoped();
- this._guidConfigured = true;
+ _guidRegistration = new SingletonRegistration { Configured = true, ImplementationType = typeof(SimpleGuidGenerator) };
return this;
}
///
- /// Thrown if the date/time system has already been configured.
public IRCommonBuilder WithDateTimeSystem(Action actions)
{
- Guard.Against(this._dateTimeConfigured,
- "Date/Time System has already been configured once. You cannot configure multiple times");
+ if (_dateTimeRegistration.Configured)
+ {
+ // Only one impl type exists; always idempotent. Still append the options delegate so
+ // additional configuration accumulates per Options pattern.
+ this.Services.Configure(actions);
+ return this;
+ }
+
this.Services.Configure(actions);
this.Services.AddTransient();
- this._dateTimeConfigured = true;
+ _dateTimeRegistration = new SingletonRegistration { Configured = true, ImplementationType = typeof(SystemTime) };
return this;
}
@@ -94,5 +127,61 @@ public virtual IServiceCollection Configure()
{
return this.Services;
}
+
+ ///
+ public TSubBuilder GetOrAddBuilder(Func factory)
+ where TSubBuilder : class
+ {
+ if (_subBuilderCache.TryGetValue(typeof(TSubBuilder), out var cached))
+ {
+ return (TSubBuilder)cached;
+ }
+
+ var built = factory();
+ _subBuilderCache[typeof(TSubBuilder)] = built;
+ return built;
+ }
+
+ ///
+ /// Walks the sub-builder cache and returns the first cached concrete type that is assignable
+ /// to , or null if no such cached builder exists.
+ ///
+ ///
+ /// Used by singleton-style WithX verbs defined outside RCommon.Core to detect "different T"
+ /// configuration conflicts before delegating to .
+ ///
+ internal Type? TryGetCachedSubBuilderImplementing(Type interfaceType)
+ {
+ foreach (var key in _subBuilderCache.Keys)
+ {
+ if (interfaceType.IsAssignableFrom(key))
+ {
+ return key;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Atomically marks the diagnostics scanner as having run. Returns true on first call,
+ /// false thereafter. Used by to
+ /// ensure the duplicate-registration scan executes at most once per builder instance.
+ ///
+ internal bool TrySetDiagnosticsRun()
+ {
+ if (_diagnosticsRun) return false;
+ _diagnosticsRun = true;
+ return true;
+ }
+
+ ///
+ /// Stashes the soft-duplicate diagnostic report so it can be retrieved later via
+ /// when no
+ /// was available to log the warning directly.
+ ///
+ internal void StashDiagnostics(string message) => _bootstrapDiagnostics = message;
+
+ ///
+ public string GetBootstrapDiagnostics() => _bootstrapDiagnostics;
}
}
diff --git a/Src/RCommon.Core/SingletonRegistration.cs b/Src/RCommon.Core/SingletonRegistration.cs
new file mode 100644
index 00000000..7b432cf8
--- /dev/null
+++ b/Src/RCommon.Core/SingletonRegistration.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace RCommon
+{
+ ///
+ /// Tracks whether a singleton-style RCommon verb has been configured and which implementation
+ /// type was chosen. Used by verbs like WithSimpleGuidGenerator and WithDateTimeSystem
+ /// to enforce same-type-idempotent / different-type-throw semantics across modular calls.
+ ///
+ internal struct SingletonRegistration
+ {
+ public bool Configured;
+ public Type? ImplementationType;
+ }
+}
diff --git a/Src/RCommon.Emailing/EmailingBuilderExtensions.cs b/Src/RCommon.Emailing/EmailingBuilderExtensions.cs
index 4a31f700..461a3fde 100644
--- a/Src/RCommon.Emailing/EmailingBuilderExtensions.cs
+++ b/Src/RCommon.Emailing/EmailingBuilderExtensions.cs
@@ -1,4 +1,5 @@
-using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using RCommon.Emailing;
using RCommon.Emailing.Smtp;
using System;
@@ -21,10 +22,23 @@ public static class EmailingBuilderExtensions
/// The RCommon builder to configure.
/// A delegate to configure .
/// The same instance for fluent chaining.
+ ///
+ /// Thrown when has already been registered with a different
+ /// implementation type, since mixing email service implementations is unsupported.
+ ///
public static IRCommonBuilder WithSmtpEmailServices(this IRCommonBuilder config, Action emailSettings)
{
+ var existing = config.Services.FirstOrDefault(d => d.ServiceType == typeof(IEmailService));
+ if (existing?.ImplementationType is { } existingImpl && existingImpl != typeof(SmtpEmailService))
+ {
+ throw new RCommonBuilderException(
+ $"IEmailService already configured as '{existingImpl.FullName}'; cannot reconfigure as '{typeof(SmtpEmailService).FullName}'. " +
+ "To configure multiple modules consistently, ensure all modules agree on the same IEmailService implementation, " +
+ "or designate a single composition root that performs this registration.");
+ }
+
config.Services.Configure(emailSettings);
- config.Services.AddTransient();
+ config.Services.TryAddTransient();
return config;
}
}
diff --git a/Src/RCommon.FluentValidation/ValidationBuilderExtensions.cs b/Src/RCommon.FluentValidation/ValidationBuilderExtensions.cs
index 37cbc065..1f331b12 100644
--- a/Src/RCommon.FluentValidation/ValidationBuilderExtensions.cs
+++ b/Src/RCommon.FluentValidation/ValidationBuilderExtensions.cs
@@ -23,7 +23,7 @@ public static class ValidationBuilderExtensions
/// The RCommon builder instance.
/// The for further chaining.
public static IRCommonBuilder WithValidation(this IRCommonBuilder builder)
- where T : IValidationBuilder
+ where T : class, IValidationBuilder
{
return WithValidation(builder, x => { });
@@ -43,11 +43,13 @@ public static IRCommonBuilder WithValidation(this IRCommonBuilder builder)
/// is expected to register its validation services into the DI container.
///
public static IRCommonBuilder WithValidation(this IRCommonBuilder builder, Action actions)
- where T : IValidationBuilder
+ where T : class, IValidationBuilder
{
- // Instantiate the validation builder via reflection; the constructor registers validation services into DI
- var cqrsBuilder = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!;
+ // Instantiate the validation builder via reflection; the constructor registers validation services into DI.
+ // Routed through GetOrAddBuilder so repeated WithValidation calls reuse the cached sub-builder.
+ var cqrsBuilder = builder.GetOrAddBuilder(
+ () => (T)Activator.CreateInstance(typeof(T), new object[] { builder })!);
builder.Services.Configure(actions);
return builder;
}
diff --git a/Src/RCommon.Json/JsonBuilderExtensions.cs b/Src/RCommon.Json/JsonBuilderExtensions.cs
index 53077341..062986f3 100644
--- a/Src/RCommon.Json/JsonBuilderExtensions.cs
+++ b/Src/RCommon.Json/JsonBuilderExtensions.cs
@@ -1,4 +1,5 @@
-using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection;
+using RCommon.Bootstrapping;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -26,7 +27,7 @@ public static class JsonBuilderExtensions
/// The RCommon builder instance.
/// The for further chaining.
public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder)
- where T : IJsonBuilder
+ where T : class, IJsonBuilder
{
return WithJsonSerialization(builder, x => { }, x => { }, x => { });
}
@@ -41,7 +42,7 @@ public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder buil
/// The for further chaining.
public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder, Action serializeOptions,
Action deSerializeOptions)
- where T : IJsonBuilder
+ where T : class, IJsonBuilder
{
return WithJsonSerialization(builder, serializeOptions, deSerializeOptions, x => { });
}
@@ -54,7 +55,7 @@ public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder buil
/// An action to configure .
/// The for further chaining.
public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder, Action serializeOptions)
- where T : IJsonBuilder
+ where T : class, IJsonBuilder
{
return WithJsonSerialization(builder, serializeOptions, x => { }, x => { });
}
@@ -68,7 +69,7 @@ public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder buil
/// The for further chaining.
public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder,
Action deSerializeOptions)
- where T : IJsonBuilder
+ where T : class, IJsonBuilder
{
return WithJsonSerialization(builder, x => { }, deSerializeOptions, x => { });
}
@@ -81,7 +82,7 @@ public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder buil
/// An action to further configure the builder instance.
/// The for further chaining.
public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder, Action actions)
- where T : IJsonBuilder
+ where T : class, IJsonBuilder
{
return WithJsonSerialization(builder, x => { }, x => { }, actions);
@@ -98,21 +99,40 @@ public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder buil
/// An action to further configure the builder instance.
/// The for further chaining.
///
+ /// Singleton-style: re-invoking with the same is idempotent (the sub-builder
+ /// is cached via so the
+ /// constructor runs once); the delegate still runs each call.
+ /// Re-invoking with a different concrete after one is already configured
+ /// throws .
/// This method uses to instantiate the builder,
/// passing the as the constructor argument. The builder's constructor
/// is expected to register its serialization services into the DI container.
///
public static IRCommonBuilder WithJsonSerialization(this IRCommonBuilder builder, Action serializeOptions,
Action deSerializeOptions, Action actions)
- where T : IJsonBuilder
+ where T : class, IJsonBuilder
{
Guard.IsNotNull(serializeOptions, nameof(serializeOptions));
Guard.IsNotNull(deSerializeOptions, nameof(deSerializeOptions));
Guard.IsNotNull(actions, nameof(actions));
+
+ var existing = RCommonBuilderInternals.FindCachedImplementationOf(builder);
+ if (existing is not null && existing != typeof(T))
+ {
+ throw new RCommonBuilderException(
+ $"IJsonBuilder already configured as '{existing.FullName}'; " +
+ $"cannot reconfigure as '{typeof(T).FullName}'. " +
+ "To configure multiple modules consistently, ensure all modules agree on the same JSON serialization implementation.");
+ }
+
builder.Services.Configure(serializeOptions);
+ // NOTE: deSerializeOptions is intentionally not wired up here. The existing implementation
+ // also does not call Configure. Preserving that pre-existing behavior
+ // keeps this change scope-limited to the singleton-style migration; the missing wiring is
+ // tracked separately as a follow-up.
- // Instantiate the builder via reflection; the constructor registers serializer services into DI
- var jsonConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!;
+ var jsonConfig = builder.GetOrAddBuilder(
+ () => (T)Activator.CreateInstance(typeof(T), new object[] { builder })!);
actions(jsonConfig);
return builder;
}
diff --git a/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachineBuilderExtensions.cs b/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachineBuilderExtensions.cs
index 06875360..3f190200 100644
--- a/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachineBuilderExtensions.cs
+++ b/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachineBuilderExtensions.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using RCommon.MassTransit.StateMachines;
using RCommon.StateMachines;
@@ -18,7 +19,7 @@ public static class MassTransitStateMachineBuilderExtensions
/// The for further chaining.
public static IRCommonBuilder WithMassTransitStateMachine(this IRCommonBuilder builder)
{
- builder.Services.AddTransient(typeof(IStateMachineConfigurator<,>), typeof(MassTransitStateMachineConfigurator<,>));
+ builder.Services.TryAddTransient(typeof(IStateMachineConfigurator<,>), typeof(MassTransitStateMachineConfigurator<,>));
return builder;
}
}
diff --git a/Src/RCommon.MassTransit/MassTransitEventHandlingBuilderExtensions.cs b/Src/RCommon.MassTransit/MassTransitEventHandlingBuilderExtensions.cs
index bc005fae..a6bd2b0b 100644
--- a/Src/RCommon.MassTransit/MassTransitEventHandlingBuilderExtensions.cs
+++ b/Src/RCommon.MassTransit/MassTransitEventHandlingBuilderExtensions.cs
@@ -33,7 +33,8 @@ public static class MassTransitEventHandlingBuilderExtensions
/// Optional configuration action for .
/// The for further chaining.
/// Thrown if MassTransit has already been registered in this container.
- private static IServiceCollection AddMassTransit(this IRCommonBuilder builder, Action? configure = null)
+ private static IServiceCollection AddMassTransit(this IRCommonBuilder builder, Action? configure = null)
+ where T : class, IMassTransitEventHandlingBuilder
{
if (builder.Services.Any(d => d.ServiceType == typeof(IBus)))
{
@@ -43,10 +44,17 @@ private static IServiceCollection AddMassTransit(this IRCommonBuilder builder, A
AddHostedService(builder.Services);
AddInstrumentation(builder.Services);
-
- var configurator = new MassTransitEventHandlingBuilder(builder);
+
+ // Routed through GetOrAddBuilder so repeated WithEventHandling calls reuse the cached sub-builder.
+ var configurator = builder.GetOrAddBuilder(
+ () => (T)Activator.CreateInstance(typeof(T), new object[] { builder })!);
configure?.Invoke(configurator);
- configurator.Complete();
+
+ // Complete() must run on the concrete MassTransit base type to finalize bus registration.
+ if (configurator is MassTransitEventHandlingBuilder mtBuilder)
+ {
+ mtBuilder.Complete();
+ }
return builder.Services;
}
@@ -84,7 +92,7 @@ private static void AddInstrumentation(IServiceCollection collection)
/// The RCommon builder.
/// The for further chaining.
public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder)
- where T : IMassTransitEventHandlingBuilder
+ where T : class, IMassTransitEventHandlingBuilder
{
return WithEventHandling(builder, x => { });
}
@@ -99,13 +107,13 @@ public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder)
/// Configuration delegate for MassTransit event handling.
/// The for further chaining.
public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, Action actions)
- where T : IMassTransitEventHandlingBuilder
+ where T : class, IMassTransitEventHandlingBuilder
{
-
+
// MassTransit Event Bus
builder.Services.AddScoped(typeof(IMassTransitEventHandler<>), typeof(MassTransitEventHandler<>));
builder.Services.AddScoped(typeof(MassTransitEventHandler<>));
- builder.AddMassTransit(actions);
+ builder.AddMassTransit(actions);
return builder;
}
diff --git a/Src/RCommon.Mediator/MediatorBuilderExtensions.cs b/Src/RCommon.Mediator/MediatorBuilderExtensions.cs
index fe70defd..898982f7 100644
--- a/Src/RCommon.Mediator/MediatorBuilderExtensions.cs
+++ b/Src/RCommon.Mediator/MediatorBuilderExtensions.cs
@@ -25,7 +25,7 @@ public static class MediatorBuilderExtensions
/// The RCommon builder instance.
/// The for chaining additional configuration calls.
public static IRCommonBuilder WithMediator(this IRCommonBuilder builder)
- where T : IMediatorBuilder
+ where T : class, IMediatorBuilder
{
return WithMediator(builder, x => { });
}
@@ -45,13 +45,15 @@ public static IRCommonBuilder WithMediator(this IRCommonBuilder builder)
/// the configuration delegate to allow library-specific setup.
///
public static IRCommonBuilder WithMediator(this IRCommonBuilder builder, Action actions)
- where T : IMediatorBuilder
+ where T : class, IMediatorBuilder
{
builder.Services.AddScoped();
- // Create the mediator-specific builder by convention (expects a constructor accepting IRCommonBuilder)
- var mediatorConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!;
+ // Create the mediator-specific builder by convention (expects a constructor accepting IRCommonBuilder).
+ // Routed through GetOrAddBuilder so repeated WithMediator calls reuse the cached sub-builder.
+ var mediatorConfig = builder.GetOrAddBuilder(
+ () => (T)Activator.CreateInstance(typeof(T), new object[] { builder })!);
actions(mediatorConfig);
return builder;
}
diff --git a/Src/RCommon.Mediatr/MediatREventHandlingBuilderExtensions.cs b/Src/RCommon.Mediatr/MediatREventHandlingBuilderExtensions.cs
index caf931c8..3a916e68 100644
--- a/Src/RCommon.Mediatr/MediatREventHandlingBuilderExtensions.cs
+++ b/Src/RCommon.Mediatr/MediatREventHandlingBuilderExtensions.cs
@@ -28,7 +28,7 @@ public static class MediatREventHandlingBuilderExtensions
/// The RCommon builder.
/// The for further chaining.
public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder)
- where T : IMediatREventHandlingBuilder
+ where T : class, IMediatREventHandlingBuilder
{
return WithEventHandling(builder, x => { }, x=> { });
}
@@ -41,7 +41,7 @@ public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder)
/// Configuration delegate for MediatR event handling.
/// The for further chaining.
public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, Action actions)
- where T : IMediatREventHandlingBuilder
+ where T : class, IMediatREventHandlingBuilder
{
// MediatR
WithEventHandling(builder, actions, mediatrActions =>
@@ -63,15 +63,17 @@ public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder,
/// The for further chaining.
public static IRCommonBuilder WithEventHandling(this IRCommonBuilder builder, Action actions,
Action mediatRActions)
- where T : IMediatREventHandlingBuilder
+ where T : class, IMediatREventHandlingBuilder
{
builder.Services.AddScoped();
// MediatR
builder.Services.AddMediatR(mediatRActions);
- // This will wire up common event handling
- var eventHandlingConfig = (T)Activator.CreateInstance(typeof(T), new object[] { builder })!;
+ // This will wire up common event handling.
+ // Routed through GetOrAddBuilder so repeated WithEventHandling calls reuse the cached sub-builder.
+ var eventHandlingConfig = builder.GetOrAddBuilder(
+ () => (T)Activator.CreateInstance(typeof(T), new object[] { builder })!);
actions(eventHandlingConfig);
return builder;
diff --git a/Src/RCommon.MultiTenancy/MultiTenancyBuilderExtensions.cs b/Src/RCommon.MultiTenancy/MultiTenancyBuilderExtensions.cs
index acf81568..c6d2405c 100644
--- a/Src/RCommon.MultiTenancy/MultiTenancyBuilderExtensions.cs
+++ b/Src/RCommon.MultiTenancy/MultiTenancyBuilderExtensions.cs
@@ -16,9 +16,12 @@ public static class MultiTenancyBuilderExtensions
/// An action to configure the multitenancy provider.
/// The for fluent chaining.
public static IRCommonBuilder WithMultiTenancy(this IRCommonBuilder builder, Action actions)
- where TBuilder : IMultiTenantBuilder
+ where TBuilder : class, IMultiTenantBuilder
{
- var multiTenantBuilder = (TBuilder)Activator.CreateInstance(typeof(TBuilder), new object[] { builder.Services })!;
+ // Routed through GetOrAddBuilder so repeated WithMultiTenancy calls for the same
+ // provider type reuse the cached sub-builder rather than constructing a fresh one each call.
+ var multiTenantBuilder = builder.GetOrAddBuilder(
+ () => (TBuilder)Activator.CreateInstance(typeof(TBuilder), new object[] { builder.Services })!);
actions(multiTenantBuilder);
return builder;
}
diff --git a/Src/RCommon.Persistence/DataStoreFactoryOptions.cs b/Src/RCommon.Persistence/DataStoreFactoryOptions.cs
index 49701521..6605df82 100644
--- a/Src/RCommon.Persistence/DataStoreFactoryOptions.cs
+++ b/Src/RCommon.Persistence/DataStoreFactoryOptions.cs
@@ -25,22 +25,36 @@ public class DataStoreFactoryOptions
/// The base data store type (e.g., a provider-specific DbContext base).
/// The concrete data store type that implements .
/// A unique name identifying the data store registration.
+ ///
+ /// Re-registering the exact same (name, base, concrete) triple is a no-op so modules can
+ /// safely declare the same data store independently. A conflicting registration (same
+ /// and base but a different concrete
+ /// ) throws.
+ ///
///
- /// Thrown when a data store with the same and base type is already registered.
+ /// Thrown when a data store with the same and base type
+ /// is already registered with a different concrete type than
+ /// .
///
public void Register(string name)
where B : IDataStore
where C : IDataStore
{
- // Prevent duplicate registrations with the same name and base type
- if (!Values.Any(x => x.Name == name && x.BaseType == typeof(B)))
+ var existing = Values.FirstOrDefault(x => x.Name == name && x.BaseType == typeof(B));
+ if (existing is null)
{
Values.Add(new DataStoreValue(name, typeof(B), typeof(C)));
+ return;
}
- else
+
+ if (existing.ConcreteType == typeof(C))
{
- throw new UnsupportedDataStoreException($"You cannot register a data store with the same name of {name} as an existing one with the same base type of {typeof(B).GetGenericTypeName()}");
+ return;
}
+
+ throw new UnsupportedDataStoreException(
+ $"Data store '{name}' for base type '{typeof(B).GetGenericTypeName()}' is already registered with concrete type " +
+ $"'{existing.ConcreteType.GetGenericTypeName()}'; cannot reconfigure as '{typeof(C).GetGenericTypeName()}'.");
}
}
}
diff --git a/Src/RCommon.Persistence/PersistenceBuilderExtensions.cs b/Src/RCommon.Persistence/PersistenceBuilderExtensions.cs
index 4eabb1a5..2490f5a1 100644
--- a/Src/RCommon.Persistence/PersistenceBuilderExtensions.cs
+++ b/Src/RCommon.Persistence/PersistenceBuilderExtensions.cs
@@ -28,7 +28,7 @@ public static class PersistenceBuilderExtensions
/// The RCommon builder instance.
/// The for fluent chaining.
public static IRCommonBuilder WithPersistence(this IRCommonBuilder builder)
- where TObjectAccess : IPersistenceBuilder
+ where TObjectAccess : class, IPersistenceBuilder
{
return WithPersistence(builder, x => { });
}
@@ -41,13 +41,13 @@ public static IRCommonBuilder WithPersistence(this IRCommonBuilde
/// An action to configure the persistence provider (e.g., register data stores).
/// The for fluent chaining.
public static IRCommonBuilder WithPersistence(this IRCommonBuilder builder, Action objectAccessActions)
- where TObjectAccess : IPersistenceBuilder
+ where TObjectAccess : class, IPersistenceBuilder
{
// Register NullTenantIdAccessor as default; concrete multitenancy packages override this
builder.Services.TryAddTransient();
- // Create the persistence builder via Activator since the concrete type is not known at compile time
- var dataConfiguration = (TObjectAccess)Activator.CreateInstance(typeof(TObjectAccess), new object[] { builder.Services })!;
+ var dataConfiguration = builder.GetOrAddBuilder(
+ () => (TObjectAccess)Activator.CreateInstance(typeof(TObjectAccess), new object[] { builder.Services })!);
objectAccessActions(dataConfiguration);
builder = WithEventTracking(builder);
return builder;
@@ -61,9 +61,10 @@ public static IRCommonBuilder WithPersistence(this IRCommonBuilde
/// An action to configure the unit of work (e.g., set isolation level, auto-complete).
/// The for fluent chaining.
public static IRCommonBuilder WithUnitOfWork(this IRCommonBuilder builder, Action unitOfWorkActions)
- where TUnitOfWork : IUnitOfWorkBuilder
+ where TUnitOfWork : class, IUnitOfWorkBuilder
{
- var unitOfWorkConfiguration = (TUnitOfWork)Activator.CreateInstance(typeof(TUnitOfWork), new object[] { builder.Services })!;
+ var unitOfWorkConfiguration = builder.GetOrAddBuilder(
+ () => (TUnitOfWork)Activator.CreateInstance(typeof(TUnitOfWork), new object[] { builder.Services })!);
unitOfWorkActions(unitOfWorkConfiguration);
return builder;
}
@@ -76,8 +77,8 @@ public static IRCommonBuilder WithUnitOfWork(this IRCommonBuilder b
/// Updated instance of RCommon Configuration
private static IRCommonBuilder WithEventTracking(this IRCommonBuilder builder)
{
- builder.Services.AddScoped();
- builder.Services.AddScoped();
+ builder.Services.TryAddScoped();
+ builder.Services.TryAddScoped();
return builder;
}
@@ -92,8 +93,8 @@ private static IRCommonBuilder WithEventTracking(this IRCommonBuilder builder)
///
[Obsolete("This is deprecated as peristence is decoupled from unit of work.")]
public static IRCommonBuilder WithPersistence(this IRCommonBuilder builder)
- where TObjectAccess: IPersistenceBuilder
- where TUnitOfWork : IUnitOfWorkBuilder
+ where TObjectAccess : class, IPersistenceBuilder
+ where TUnitOfWork : class, IUnitOfWorkBuilder
{
return WithPersistence(builder, x => { }, x => { });
}
@@ -110,8 +111,8 @@ public static IRCommonBuilder WithPersistence(this I
[Obsolete("This is deprecated as peristence is decoupled from unit of work.")]
public static IRCommonBuilder WithPersistence(this IRCommonBuilder builder,
Action objectAccessActions)
- where TObjectAccess : IPersistenceBuilder
- where TUnitOfWork : IUnitOfWorkBuilder
+ where TObjectAccess : class, IPersistenceBuilder
+ where TUnitOfWork : class, IUnitOfWorkBuilder
{
return WithPersistence(builder, objectAccessActions, x => { });
}
@@ -128,8 +129,8 @@ public static IRCommonBuilder WithPersistence(this I
[Obsolete("This is deprecated as peristence is decoupled from unit of work.")]
public static IRCommonBuilder WithPersistence(this IRCommonBuilder builder,
Action uniOfWorkActions)
- where TObjectAccess : IPersistenceBuilder
- where TUnitOfWork : IUnitOfWorkBuilder
+ where TObjectAccess : class, IPersistenceBuilder
+ where TUnitOfWork : class, IUnitOfWorkBuilder
{
return WithPersistence(builder, x => { }, uniOfWorkActions);
}
@@ -145,14 +146,16 @@ public static IRCommonBuilder WithPersistence(this I
///
///
[Obsolete("This is deprecated as peristence is decoupled from unit of work.")]
- public static IRCommonBuilder WithPersistence(this IRCommonBuilder builder,
+ public static IRCommonBuilder WithPersistence(this IRCommonBuilder builder,
Action objectAccessActions, Action unitOfWorkActions)
- where TObjectAccess : IPersistenceBuilder
- where TUnitOfWork : IUnitOfWorkBuilder
+ where TObjectAccess : class, IPersistenceBuilder
+ where TUnitOfWork : class, IUnitOfWorkBuilder
{
- var dataConfiguration = (TObjectAccess)Activator.CreateInstance(typeof(TObjectAccess), new object[] { builder.Services })!;
+ var dataConfiguration = builder.GetOrAddBuilder(
+ () => (TObjectAccess)Activator.CreateInstance(typeof(TObjectAccess), new object[] { builder.Services })!);
objectAccessActions(dataConfiguration);
- var unitOfWorkConfiguration = (TUnitOfWork)Activator.CreateInstance(typeof(TUnitOfWork), new object[] { builder.Services })!;
+ var unitOfWorkConfiguration = builder.GetOrAddBuilder(
+ () => (TUnitOfWork)Activator.CreateInstance(typeof(TUnitOfWork), new object[] { builder.Services })!);
unitOfWorkActions(unitOfWorkConfiguration);
builder = WithEventTracking(builder);
return builder;
diff --git a/Src/RCommon.Security/SecurityConfigurationExtensions.cs b/Src/RCommon.Security/SecurityConfigurationExtensions.cs
index 3e502b16..6048e3fd 100644
--- a/Src/RCommon.Security/SecurityConfigurationExtensions.cs
+++ b/Src/RCommon.Security/SecurityConfigurationExtensions.cs
@@ -4,6 +4,7 @@
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using RCommon.Security.Users;
using RCommon.Security.Claims;
using RCommon.Security.Clients;
@@ -24,10 +25,10 @@ public static class SecurityConfigurationExtensions
/// The same instance for fluent chaining.
public static IRCommonBuilder WithClaimsAndPrincipalAccessor(this IRCommonBuilder config)
{
- config.Services.AddTransient();
- config.Services.AddTransient();
- config.Services.AddTransient();
- config.Services.AddTransient();
+ config.Services.TryAddTransient();
+ config.Services.TryAddTransient();
+ config.Services.TryAddTransient();
+ config.Services.TryAddTransient();
return config;
}
}
diff --git a/Src/RCommon.SendGrid/SendGridEmailingConfigurationExtensions.cs b/Src/RCommon.SendGrid/SendGridEmailingConfigurationExtensions.cs
index 73431c90..ff9b3dbe 100644
--- a/Src/RCommon.SendGrid/SendGridEmailingConfigurationExtensions.cs
+++ b/Src/RCommon.SendGrid/SendGridEmailingConfigurationExtensions.cs
@@ -1,4 +1,5 @@
-using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using RCommon.Emailing;
using RCommon.Emailing.SendGrid;
using RCommon.Emailing.Smtp;
@@ -22,10 +23,23 @@ public static class SendGridEmailingConfigurationExtensions
/// The RCommon builder to configure.
/// A delegate to configure .
/// The same instance for fluent chaining.
+ ///
+ /// Thrown when has already been registered with a different
+ /// implementation type, since mixing email service implementations is unsupported.
+ ///
public static IRCommonBuilder WithSendGridEmailServices(this IRCommonBuilder config, Action emailSettings)
{
+ var existing = config.Services.FirstOrDefault(d => d.ServiceType == typeof(IEmailService));
+ if (existing?.ImplementationType is { } existingImpl && existingImpl != typeof(SendGridEmailService))
+ {
+ throw new RCommonBuilderException(
+ $"IEmailService already configured as '{existingImpl.FullName}'; cannot reconfigure as '{typeof(SendGridEmailService).FullName}'. " +
+ "To configure multiple modules consistently, ensure all modules agree on the same IEmailService implementation, " +
+ "or designate a single composition root that performs this registration.");
+ }
+
config.Services.Configure(emailSettings);
- config.Services.AddTransient();
+ config.Services.TryAddTransient();
return config;
}
}
diff --git a/Src/RCommon.Stateless/StatelessBuilderExtensions.cs b/Src/RCommon.Stateless/StatelessBuilderExtensions.cs
index 8ebb4035..a7ce47c3 100644
--- a/Src/RCommon.Stateless/StatelessBuilderExtensions.cs
+++ b/Src/RCommon.Stateless/StatelessBuilderExtensions.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using RCommon.Stateless;
using RCommon.StateMachines;
@@ -18,7 +19,7 @@ public static class StatelessBuilderExtensions
/// The for further chaining.
public static IRCommonBuilder WithStatelessStateMachine(this IRCommonBuilder builder)
{
- builder.Services.AddTransient(typeof(IStateMachineConfigurator<,>), typeof(StatelessConfigurator<,>));
+ builder.Services.TryAddTransient(typeof(IStateMachineConfigurator<,>), typeof(StatelessConfigurator<,>));
return builder;
}
}
diff --git a/Src/RCommon.Web/WebConfigurationExtensions.cs b/Src/RCommon.Web/WebConfigurationExtensions.cs
index 60065005..8119a11f 100644
--- a/Src/RCommon.Web/WebConfigurationExtensions.cs
+++ b/Src/RCommon.Web/WebConfigurationExtensions.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using RCommon.Security.Claims;
using RCommon.Security.Clients;
using RCommon.Security.Users;
@@ -27,10 +28,10 @@ public static class WebConfigurationExtensions
public static IRCommonBuilder WithClaimsAndPrincipalAccessorForWeb(this IRCommonBuilder config)
{
config.Services.AddHttpContextAccessor();
- config.Services.AddTransient();
- config.Services.AddTransient();
- config.Services.AddTransient();
- config.Services.AddTransient();
+ config.Services.TryAddTransient();
+ config.Services.TryAddTransient();
+ config.Services.TryAddTransient();
+ config.Services.TryAddTransient();
return config;
}
}
diff --git a/Tests/RCommon.Caching.Tests/CachingBuilderExtensionsTests.cs b/Tests/RCommon.Caching.Tests/CachingBuilderExtensionsTests.cs
index cd031c76..f6f18b66 100644
--- a/Tests/RCommon.Caching.Tests/CachingBuilderExtensionsTests.cs
+++ b/Tests/RCommon.Caching.Tests/CachingBuilderExtensionsTests.cs
@@ -290,7 +290,7 @@ public void FluentChaining_WithOtherBuilderMethods_WorksCorrectly()
#region Builder Instance Creation Tests
[Fact]
- public void WithMemoryCaching_CreatesNewBuilderInstanceEachTime()
+ public void WithMemoryCaching_ReusesCachedBuilderForRepeatedCalls()
{
// Arrange
var services = new ServiceCollection();
@@ -303,13 +303,15 @@ public void WithMemoryCaching_CreatesNewBuilderInstanceEachTime()
rcommonBuilder.WithMemoryCaching(builder => secondBuilder = builder);
// Assert
+ // The action delegate still runs each call, but the sub-builder is cached
+ // via IRCommonBuilder.GetOrAddBuilder so both invocations see the same instance.
firstBuilder.Should().NotBeNull();
secondBuilder.Should().NotBeNull();
- firstBuilder.Should().NotBeSameAs(secondBuilder);
+ firstBuilder.Should().BeSameAs(secondBuilder);
}
[Fact]
- public void WithDistributedCaching_CreatesNewBuilderInstanceEachTime()
+ public void WithDistributedCaching_ReusesCachedBuilderForRepeatedCalls()
{
// Arrange
var services = new ServiceCollection();
@@ -322,9 +324,11 @@ public void WithDistributedCaching_CreatesNewBuilderInstanceEachTime()
rcommonBuilder.WithDistributedCaching(builder => secondBuilder = builder);
// Assert
+ // The action delegate still runs each call, but the sub-builder is cached
+ // via IRCommonBuilder.GetOrAddBuilder so both invocations see the same instance.
firstBuilder.Should().NotBeNull();
secondBuilder.Should().NotBeNull();
- firstBuilder.Should().NotBeSameAs(secondBuilder);
+ firstBuilder.Should().BeSameAs(secondBuilder);
}
#endregion
diff --git a/Tests/RCommon.Core.Tests/Bootstrapping/AddRCommonIdempotencyTests.cs b/Tests/RCommon.Core.Tests/Bootstrapping/AddRCommonIdempotencyTests.cs
new file mode 100644
index 00000000..9d21ecb3
--- /dev/null
+++ b/Tests/RCommon.Core.Tests/Bootstrapping/AddRCommonIdempotencyTests.cs
@@ -0,0 +1,85 @@
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using RCommon.EventHandling;
+using RCommon.EventHandling.Producers;
+using Xunit;
+
+namespace RCommon.Core.Tests.Bootstrapping;
+
+public class AddRCommonIdempotencyTests
+{
+ [Fact]
+ public void AddRCommon_CalledTwice_ReturnsSameBuilderInstance()
+ {
+ var services = new ServiceCollection();
+
+ var first = services.AddRCommon();
+ var second = services.AddRCommon();
+
+ second.Should().BeSameAs(first);
+ }
+
+ [Fact]
+ public void AddRCommon_CalledTwice_RegistersEventBusOnlyOnce()
+ {
+ var services = new ServiceCollection();
+
+ services.AddRCommon();
+ services.AddRCommon();
+
+ services.Count(d => d.ServiceType == typeof(IEventBus)).Should().Be(1);
+ }
+
+ [Fact]
+ public void AddRCommon_CalledTwice_RegistersEventSubscriptionManagerOnlyOnce()
+ {
+ var services = new ServiceCollection();
+
+ services.AddRCommon();
+ services.AddRCommon();
+
+ services.Count(d => d.ServiceType == typeof(EventSubscriptionManager)).Should().Be(1);
+ }
+
+ [Fact]
+ public void AddRCommon_CalledTwice_RegistersEventRouterOnlyOnce()
+ {
+ var services = new ServiceCollection();
+
+ services.AddRCommon();
+ services.AddRCommon();
+
+ services.Count(d => d.ServiceType == typeof(IEventRouter)).Should().Be(1);
+ }
+
+ [Fact]
+ public void AddRCommon_CalledTwice_HasIdenticalDescriptorCountToCalledOnce()
+ {
+ var servicesA = new ServiceCollection();
+ var servicesB = new ServiceCollection();
+
+ servicesA.AddRCommon();
+ servicesB.AddRCommon();
+ servicesB.AddRCommon();
+
+ servicesB.Count.Should().Be(servicesA.Count);
+ }
+
+ [Fact]
+ public void IsRCommonInitialized_BeforeAddRCommon_ReturnsFalse()
+ {
+ var services = new ServiceCollection();
+
+ services.IsRCommonInitialized().Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsRCommonInitialized_AfterAddRCommon_ReturnsTrue()
+ {
+ var services = new ServiceCollection();
+
+ services.AddRCommon();
+
+ services.IsRCommonInitialized().Should().BeTrue();
+ }
+}
diff --git a/Tests/RCommon.Core.Tests/Bootstrapping/EventProducerDedupTests.cs b/Tests/RCommon.Core.Tests/Bootstrapping/EventProducerDedupTests.cs
new file mode 100644
index 00000000..1e67e971
--- /dev/null
+++ b/Tests/RCommon.Core.Tests/Bootstrapping/EventProducerDedupTests.cs
@@ -0,0 +1,64 @@
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using RCommon.EventHandling;
+using RCommon.EventHandling.Producers;
+using RCommon.Models.Events;
+using Xunit;
+
+namespace RCommon.Core.Tests.Bootstrapping;
+
+public class EventProducerDedupTests
+{
+ [Fact]
+ public void AddProducer_SameTypeCalledTwice_RegistersOnce()
+ {
+ var services = new ServiceCollection();
+ var builder = services.AddRCommon();
+
+ builder.WithEventHandling(eh =>
+ {
+ eh.AddProducer();
+ eh.AddProducer();
+ });
+
+ var producerDescriptors = services
+ .Where(d => d.ServiceType == typeof(IEventProducer) && d.ImplementationType == typeof(TestProducer))
+ .ToList();
+ producerDescriptors.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public void AddProducer_DifferentTypes_RegistersBoth()
+ {
+ var services = new ServiceCollection();
+ var builder = services.AddRCommon();
+
+ builder.WithEventHandling(eh =>
+ {
+ eh.AddProducer();
+ eh.AddProducer();
+ });
+
+ services.Count(d => d.ServiceType == typeof(IEventProducer)).Should().Be(2);
+ }
+
+ public class TestEventHandlingBuilder : IEventHandlingBuilder
+ {
+ public TestEventHandlingBuilder(IRCommonBuilder builder) { Services = builder.Services; }
+ public IServiceCollection Services { get; }
+ }
+
+ public class TestProducer : IEventProducer
+ {
+ public Task ProduceEventAsync(TEvent @event, CancellationToken cancellationToken = default)
+ where TEvent : ISerializableEvent => Task.CompletedTask;
+ }
+
+ public class OtherTestProducer : IEventProducer
+ {
+ public Task ProduceEventAsync(TEvent @event, CancellationToken cancellationToken = default)
+ where TEvent : ISerializableEvent => Task.CompletedTask;
+ }
+}
diff --git a/Tests/RCommon.Core.Tests/Bootstrapping/SingletonVerbConflictTests.cs b/Tests/RCommon.Core.Tests/Bootstrapping/SingletonVerbConflictTests.cs
new file mode 100644
index 00000000..127faf32
--- /dev/null
+++ b/Tests/RCommon.Core.Tests/Bootstrapping/SingletonVerbConflictTests.cs
@@ -0,0 +1,73 @@
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace RCommon.Core.Tests.Bootstrapping;
+
+public class SingletonVerbConflictTests
+{
+ [Fact]
+ public void WithSimpleGuidGenerator_CalledTwice_IsIdempotent()
+ {
+ var services = new ServiceCollection();
+ var builder = services.AddRCommon();
+
+ builder.WithSimpleGuidGenerator();
+ Action secondCall = () => builder.WithSimpleGuidGenerator();
+
+ secondCall.Should().NotThrow();
+ services.Count(d => d.ServiceType == typeof(IGuidGenerator)).Should().Be(1);
+ }
+
+ [Fact]
+ public void WithSequentialGuidGenerator_CalledTwice_IsIdempotent()
+ {
+ var services = new ServiceCollection();
+ var builder = services.AddRCommon();
+
+ builder.WithSequentialGuidGenerator(o => { });
+ Action secondCall = () => builder.WithSequentialGuidGenerator(o => { });
+
+ secondCall.Should().NotThrow();
+ services.Count(d => d.ServiceType == typeof(IGuidGenerator)).Should().Be(1);
+ }
+
+ [Fact]
+ public void WithSimpleGuidGenerator_AfterSequentialGuidGenerator_ThrowsRCommonBuilderException()
+ {
+ var services = new ServiceCollection();
+ var builder = services.AddRCommon();
+ builder.WithSequentialGuidGenerator(o => { });
+
+ Action act = () => builder.WithSimpleGuidGenerator();
+
+ act.Should().Throw()
+ .WithMessage("*SequentialGuidGenerator*SimpleGuidGenerator*");
+ }
+
+ [Fact]
+ public void WithSequentialGuidGenerator_AfterSimpleGuidGenerator_ThrowsRCommonBuilderException()
+ {
+ var services = new ServiceCollection();
+ var builder = services.AddRCommon();
+ builder.WithSimpleGuidGenerator();
+
+ Action act = () => builder.WithSequentialGuidGenerator(o => { });
+
+ act.Should().Throw()
+ .WithMessage("*SimpleGuidGenerator*SequentialGuidGenerator*");
+ }
+
+ [Fact]
+ public void WithDateTimeSystem_CalledTwice_IsIdempotent()
+ {
+ var services = new ServiceCollection();
+ var builder = services.AddRCommon();
+
+ builder.WithDateTimeSystem(o => { });
+ Action secondCall = () => builder.WithDateTimeSystem(o => { });
+
+ secondCall.Should().NotThrow();
+ services.Count(d => d.ServiceType == typeof(ISystemTime)).Should().Be(1);
+ }
+}
diff --git a/Tests/RCommon.Core.Tests/Bootstrapping/SoftDuplicateDiagnosticsTests.cs b/Tests/RCommon.Core.Tests/Bootstrapping/SoftDuplicateDiagnosticsTests.cs
new file mode 100644
index 00000000..183f9b3b
--- /dev/null
+++ b/Tests/RCommon.Core.Tests/Bootstrapping/SoftDuplicateDiagnosticsTests.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace RCommon.Core.Tests.Bootstrapping;
+
+public class SoftDuplicateDiagnosticsTests
+{
+ [Fact]
+ public async Task HostedService_WithSoftDuplicates_EmitsSingleWarning()
+ {
+ var capturedWarnings = new List();
+ var services = new ServiceCollection();
+ services.AddSingleton(new TestLoggerFactory(capturedWarnings));
+ services.AddLogging();
+
+ services.AddRCommon();
+ // Inject a duplicate registration to trigger soft-duplicate detection
+ services.AddTransient();
+ services.AddTransient();
+
+ var provider = services.BuildServiceProvider();
+ foreach (var hs in provider.GetServices())
+ {
+ await hs.StartAsync(CancellationToken.None);
+ }
+
+ capturedWarnings.Should().HaveCount(1);
+ capturedWarnings[0].Should().Contain("FakeServiceImpl");
+ }
+
+ [Fact]
+ public async Task HostedService_NoSoftDuplicates_EmitsNoWarning()
+ {
+ var capturedWarnings = new List();
+ var services = new ServiceCollection();
+ services.AddSingleton(new TestLoggerFactory(capturedWarnings));
+ services.AddLogging();
+
+ services.AddRCommon();
+
+ var provider = services.BuildServiceProvider();
+ foreach (var hs in provider.GetServices())
+ {
+ await hs.StartAsync(CancellationToken.None);
+ }
+
+ capturedWarnings.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task HostedService_CalledTwice_OnlyRunsScannerOnce()
+ {
+ var capturedWarnings = new List();
+ var services = new ServiceCollection();
+ services.AddSingleton(new TestLoggerFactory(capturedWarnings));
+ services.AddLogging();
+
+ services.AddRCommon();
+ services.AddTransient();
+ services.AddTransient();
+
+ var provider = services.BuildServiceProvider();
+ var hostedServices = provider.GetServices().ToList();
+ foreach (var hs in hostedServices)
+ {
+ await hs.StartAsync(CancellationToken.None);
+ await hs.StartAsync(CancellationToken.None);
+ }
+
+ capturedWarnings.Should().HaveCount(1);
+ }
+
+ public interface IFakeService { }
+ public class FakeServiceImpl : IFakeService { }
+
+ private sealed class TestLoggerFactory : ILoggerFactory
+ {
+ private readonly List _warnings;
+ public TestLoggerFactory(List warnings) { _warnings = warnings; }
+ public void AddProvider(ILoggerProvider provider) { }
+ public ILogger CreateLogger(string categoryName) => new TestLogger(_warnings);
+ public void Dispose() { }
+ }
+
+ private sealed class TestLogger : ILogger
+ {
+ private readonly List _warnings;
+ public TestLogger(List warnings) { _warnings = warnings; }
+ public IDisposable? BeginScope