Documentation
adrs/003-dynamic-component-loading.md
ADR-003: Dynamic Component Loading with Assembly Load Context
Status
Accepted
Context
In traditional .NET applications, all assemblies are loaded at compile time through project references. However, our Polylith architecture requires components to be loaded dynamically at runtime to achieve true modularity and flexibility.
The challenge was how to implement component loading that would:
- Maintain loose coupling: Components should only know about interfaces, not implementations
- Support hot-swapping: Potentially replace components without restarting the application
- Handle dependencies: Manage transitive dependencies and version conflicts
- Provide discoverability: Automatically find and load available components
- Integrate with DI: Register component implementations with the dependency injection container
Several approaches were considered:
- Compile-time linking: Traditional project references (ruled out - not modular)
- MEF (Managed Extensibility Framework): .NET's built-in composition framework
- Plugin architecture: Custom assembly loading with interfaces
- Assembly Load Context: .NET Core's modern assembly loading mechanism
- Reflection-based discovery: Scan assemblies for implementations
Decision
We implemented dynamic component loading using .NET's AssemblyLoadContext combined with reflection-based discovery and dependency injection registration.
Key Design Decisions:
- Single Load Context: Use one
ComponentLoadContextfor all components to avoid isolation issues - Build-time preparation: Components are built and copied to
bin/components/directory - Runtime discovery: Scan component assemblies for interface implementations
- Automatic registration: Register implementations with DI container automatically
- Interface-driven loading: Only load components for explicitly requested interfaces
Implementation Architecture:
// ComponentLoader.cs - Main loading logic
public static void LoadComponents(this IServiceCollection services, params Type[] requestedComponents)
// ComponentLoadContext.cs - Custom assembly load context
public class ComponentLoadContext : AssemblyLoadContext
// Program.cs - Usage
services.LoadComponents(
typeof(ICoreDataApi),
typeof(IIdentityApi),
typeof(IEventsApi)
);
Build Integration:
- Each component includes MSBuild target to copy assemblies to
bin/components/ - Foundation base copies components to its output directory
- Runtime loader scans the components directory
Consequences
Positive
Architectural Benefits:
- True modularity: Components can be added/removed without recompilation
- Loose coupling: Foundation base only depends on component interfaces
- Flexible composition: Different applications can load different component sets
- Development simplicity: New components are automatically discovered
Runtime Flexibility:
- Dynamic configuration: Can potentially load components based on configuration
- Plugin architecture: External components can be added by placing assemblies in folder
- Testing benefits: Can load different implementations for testing
- Deployment options: Can deploy different component combinations
Development Experience:
- Automatic registration: No manual DI registration for each component
- Clear separation: Physical separation enforces architectural boundaries
- Hot reload potential: Foundation for hot-swapping components (future enhancement)
Negative
Runtime Complexity:
- Late binding errors: Component loading failures only discovered at runtime
- Debugging complexity: Dynamic loading can complicate debugging and profiling
- Startup overhead: Assembly scanning and loading adds application startup time
- Memory usage: All component assemblies loaded into memory regardless of usage
Dependency Management:
- Version conflicts: Multiple components might depend on different versions of same library
- Transitive dependencies: Complex dependency graphs can cause loading issues
- Assembly resolution: Custom resolution logic needed for component dependencies
- GAC conflicts: Potential conflicts with Global Assembly Cache
Operational Challenges:
- Deployment complexity: Must ensure all component assemblies are deployed
- Security concerns: Loading arbitrary assemblies could be security risk
- Diagnostic complexity: Harder to diagnose assembly loading issues
- Performance unpredictability: Loading time depends on number and size of components
Development Challenges:
- Build coordination: Components must be built before foundation base
- IDE support: Dynamic references not visible in IDE dependency graphs
- Refactoring tools: Some refactoring tools don't understand dynamic loading
- Static analysis: Code analysis tools might miss dynamically loaded dependencies
Mitigation Strategies
Runtime Error Handling:
// Detailed logging for troubleshooting
logger.LogInformation("Loading component {ComponentName}", componentName);
logger.LogError("Failed to load component {ComponentName}: {Error}", componentName, error);
// Graceful fallback for missing components
if (implementation == null) {
logger.LogWarning("No implementation found for {InterfaceName}", interfaceType.Name);
// Could register null object pattern or throw descriptive exception
}
Build System Integration:
<!-- Automatic component copying -->
<Target Name="PublishComponent" AfterTargets="Build">
<ItemGroup>
<ComponentFiles Include="$(OutputPath)*.dll;$(OutputPath)*.pdb" />
</ItemGroup>
<Copy SourceFiles="@(ComponentFiles)" DestinationFolder="$(ComponentsDir)"/>
</Target>
Development Tooling:
- Use
development.slnxto include all components for IDE support - Detailed logging during component loading
- Health checks to verify component loading
Testing Strategy:
// Integration tests that verify component loading
[Test]
public void Should_Load_All_Required_Components() {
var services = new ServiceCollection();
services.LoadComponents(typeof(ICoreDataApi), typeof(IIdentityApi));
var provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetService<ICoreDataApi>());
Assert.NotNull(provider.GetService<IIdentityApi>());
}
Implementation Details
Component Discovery Logic:
private static Type? FindImplementation(List<Assembly> assemblies, Type interfaceType) {
foreach (var assembly in assemblies) {
var types = assembly.GetTypes();
var implementation = types.FirstOrDefault(t =>
!t.IsInterface &&
!t.IsAbstract &&
interfaceType.IsAssignableFrom(t));
if (implementation != null) {
return implementation;
}
}
return null;
}
Assembly Loading:
public class ComponentLoadContext : AssemblyLoadContext {
protected override Assembly? Load(AssemblyName assemblyName) {
// Custom loading logic for component dependencies
var assemblyPath = Path.Combine(ComponentsPath, $"{assemblyName.Name}.dll");
return File.Exists(assemblyPath) ? LoadFromAssemblyPath(assemblyPath) : null;
}
}
Related Decisions
- ADR-001: Overall architecture requiring component loading
- ADR-002: Interface naming that enables clean loading
- ADR-004: Compile-time enforcement of interface-only references
Alternatives Considered
MEF (Managed Extensibility Framework)
Pros: Built-in .NET framework, attribute-based composition, mature
Cons: Heavy, complex configuration, not well-suited for our interface-driven approach
Traditional Plugin Architecture
Pros: Simple, well-understood pattern
Cons: Requires explicit plugin interfaces, more boilerplate code
Compile-time Composition
Pros: Better IDE support, earlier error detection
Cons: Loses modularity benefits, requires recompilation for component changes
Future Enhancements
- Hot Reload: Support for replacing components without application restart
- Lazy Loading: Load components only when first used
- Health Monitoring: Component health checks and automatic restart
- Version Management: Support for multiple component versions and selection
- Security: Assembly signing and verification for component loading