Library Source on GitHub
Demo Application on GitHub
Problem
In a typical DDD layered architecture (which has several layers like UI, Application Service, Domain, Repository) that makes use of dependency injection, IoC registration is normally done at composition root of the application. For ASP.NET MVC application, it’s at Global.asax or Startup.cs. Ideally we expect to have the following solution structure (which follows Onion Architecture approach - arrow represents actual project reference in Visual Studio):
However, in reality the UI project needs to reference to all other projects in the solution, even though it doesn’t need to use it (orange arrow indicates redundant Visual Studio project reference):
In the diagram above, it needs to reference Concrete Repository project, Concrete Application Services project in order to register concrete repositories/application services to their interfaces to IoC container, even though controllers don’t need to know about concrete classes.
Why is this extra referencing bad?
- It pollutes UI project with redundant references that it doesn’t need, make it more complicated that it needs
- It makes it easier for new joining members to break solution layer structure (e.g. new members can create and use concrete application services/repositories directly in controllers or can use domain models directly instead of DTO/View Model when displaying views)
Some may choose to move this dependency registration to a separate project (e.g. Bootstrapper):
But this doesn’t solve the problem above, it just moves the problem to another project. Either way, the solution has a “God” project that knows about all other projects
Approach
To solve the above problem, my approach is to let each project be responsible for its own configuration and initialization. Each project has its own Bootstrapper class that does IoC registration and other initialization (e.g. AutoMapper configuration, database seeding):
UI project creates and configures a BootstrapperLoader, which in turn triggers methods from Bootstrapper classes through reflection. In the picture above, green arrows indicate methods triggering through reflection (not project references)
I found myself keep repeating this setup for each new project, so I decided to separate that logic into a tiny library (Sharpenter.BootstrapperLoader) which can be included through NuGet.
Demo Walkthrough
Included is a .NET Core web application that demonstrates usage of Sharpenter.BootstrapperLoader library. This is a simplified version of the architecture mentioned above and it has only 3 layers: UI (ASP.NET MVC Core), Core (Models + Repository Interfaces) and Repositories. Note that the UI project doesn’t need to have project reference to Repository project.
When started, it simply displays a list of books (retrieved from database using Entity Framework Core):
Let’s look at Bootstrapper class in BootstrapperLoaderDemo.Repository. This class has 2 methods:
- ConfigureContainer(): used to register BookRepository to ASP.NET MVC Core IServiceCollection so that it can be injected into controllers. It also configures dependency injection for Entity Framework
public void ConfigureContainer(IServiceCollection services)
{
services.AddScoped<IBookRepository, BookRepository>();
services.AddEntityFrameworkSqlServer()
.AddDbContext<BookContext>(options =>
options.UseSqlServer(_configuration.GetConnectionString("DefaultConnection"))
);
}
- ConfigureDevelopment(): since EF Core doesn’t have support for database initializer yet, I use this method for database initialization and seeding of data.
Note that this Bootstrapper class also takes IConfiguration as parameter for constructor so that it can leverage ASP.NET MVC Core configuration system for database connection string.
When application is started, ASP.NET MVC will call methods from Startup class in BootstrapperLoaderDemo. In the constructor of this class, in addition to normal ASP.NET MVC configuration setup, it also uses LoaderBuilder to configure and create an instance of BootstrapperLoader:
_bootstrapperLoader = new LoaderBuilder()
.Use(new FileSystemAssemblyProvider(PlatformServices.Default.Application.ApplicationBasePath, "BootstrapperLoaderDemo.*.dll"))
.ForClass()
.HasConstructorParameter(Configuration)
.When(env.IsDevelopment)
.AddMethodNameConvention("Development")
.Build();
Those lines of code tell the BootstrapperLoader to look into application base path, find all dlls that start with the text “BootstrapperLoaderDemo”, and for all Bootstrapper classes found in those dlls, pass in Configuration object as constructor parameter. AddMethodNameConvention() instructs loader to look for ConfigureDevelopmentContainer() and ConfigureDevelopment() in addition to ConfigureContainer() and Configure() by default.
Startup.ConfigureServices() is the place where ASP.NET MVC Core uses to configure dependency for the whole application. Here we use BootstrapperLoader created earlier to trigger method “ConfigureContainer()” in Repository Bootstrapper class. Note that it also passes in IServiceCollection instance so that Repository Bootstrapper can add its own registration to the same collection:
_bootstrapperLoader.TriggerConfigureContainer(services);
Startup.Configure() is used for other configuration of the application. Here we used BootstrapperLoader to trigger method “ConfigureDevelopment()” in Repository Bootstrapper. This triggering only works if current environment is development, as configured above. Also note that we pass in method GetService() so that the library can use this to resolve any parameter of ConfigureDevelopment() if have. It works the same way with Startup.Configure() where you can specify any number of parameters, as long as those are already registered in IServiceCollection. Also due to the problem here (https://stackoverflow.com/a/45268690/1273147 - during application startup, we don't have access to request scope so we need to create a scope by ourselves, in order to access services registered by .NET Core)
var scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
using (var scope = scopeFactory.CreateScope())
{
_bootstrapperLoader.TriggerConfigure(scope.ServiceProvider.GetRequiredService);
}
One caveat is that: since we no longer reference Repository project from UI project, Visual Studio/msbuild will not copy Repository dll into bin folder of UI project for us automatically. So to work around this, I use PostBuild target in UI project BootstrapperLoaderDemo.csproj to copy those dlls automatically after build. However in order for this PostBuild target to work, we need to ensure BootstrapperLoaderDemo is built after its dependency (BootstrapperLoaderDemo.Repository) has finished building. At the moment I have to create a simple powershell/bash script to build projects individually to make sure that order is maintained. This is the biggest issue when adopting this library but it just needs to be setup once at the beginning
<Target Name="PostPublish" AfterTargets="AfterPublish" >
<ItemGroup>
<ItemsToCopy Include="./../BootstrapperLoaderDemo.Repository/bin/$(Configuration)/$(TargetFramework)/publish/*" />
</ItemGroup>
<Copy SourceFiles="@(ItemsToCopy)" DestinationFolder="./bin/$(Configuration)/$(TargetFramework)/publish">
<Output TaskParameter="CopiedFiles" ItemName="SuccessfullyCopiedFiles" />
</Copy>
<Message Importance="High" Text="PostPublish Target successfully copied:%0a@(ItemsToCopy->'- %(fullpath)', '%0a')%0a -> %0a@(SuccessfullyCopiedFiles->'- %(fullpath)', '%0a')" />
</Target>
Loader Configuration
Default Configuration
By default, BootstrapperLoader
has following settings:
- use
FileSystemAssemblyProvider
to look for all *.dll
in current folder (Directory.GetCurrentDirectory()
) BootstrapperLoader.TriggerConfigure
looks for Configure()
method in any class with name Bootstrapper
in dlls found above BootstrapperLoader.TriggerConfigureContainer
looks for ConfigureContainer()
method in any class with name Bootstrapper
in dlls found above - Bootstrapper class and Configure()/ConfigureContainer() can be either public or non-public
LoaderBuilder Configuration
The loader builder offers several configurations through Fluent API:
-
WithName("SomeBootstrapper")
: look for class with name SomeBootstrapper
instead of Bootstrapper
Example:
<code>_bootstrapperLoader = new LoaderBuilder()
.ForClass()
.WithName("SomeBootstrapper")
.Build();
</code>
-
HasConstructorParameter<ISomeDependency>()
: when creating Bootstrapper
instance, use constructor that takes ISomeDependency
parameter
Example:
<code>_bootstrapperLoader = new LoaderBuilder()
.ForClass()
.HasConstructorParameter<ISomeDependency>(new SomeDependency())
.Build();
</code>
-
When(condition).CallConfigure("SomeConfigure")
: when calling BootstrapperLoader.TriggerConfigure()
, if condition
invocation is evaluated to true, call SomeConfigure()
method in Bootstrapper
classes in addition to Configure()
Example:
<code>_bootstrapperLoader = new LoaderBuilder()
.ForClass()
.When(env.IsDevelopment)
.CallConfigure("SomeConfigure")
.Build();
</code>
-
When(condition).CallConfigureContainer("SomeConfigureContainer")
: when calling BootstrapperLoader.TriggerConfigureContainer()
, if condition
invocation is evaluated to true, call SomeConfigureContainer()
method in Bootstrapper
classes in addition to ConfigureContainer()
Example:
<code>_bootstrapperLoader = new LoaderBuilder()
.ForClass()
.When(env.IsDevelopment)
.CallConfigureContainer("SomeConfigureContainer")
.Build();
</code>
-
When(condition).AddMethodNameConvention("Development")
: when calling BootstrapperLoader.TriggerConfigure()
/ BootstrapperLoader.TriggerConfigureContainer()
, if condition
invocation is evaluated to true, call SomeConfigure()
/SomeConfigureContainer()
method in Bootstrapper
classes in addition to Configure()
/ConfigureContainer()
Example:
<code>_bootstrapperLoader = new LoaderBuilder()
.ForClass()
.When(env.IsDevelopment)
.AddMethodNameConvention("Development")
.Build();
</code>
-
Use()
: specify an alternative assembly provider:
Example:
<code>_bootstrapperLoader = new LoaderBuilder()
.Use(new FileSystemAssemblyProvider(Directory.GetCurrentDirectory(), "MyCoolProject*.dll"))
.Build();
</code>
You can also create new Assembly Provider class, to customize the source of assemblies provided to the loader. At the moment, there are 2 classes provided: FileSystemAssemblyProvider and InMemoryAssemblyProvider
Trigger bootstrapper from root project
BootstrapperLoader
provides 3 methods to trigger methods in sub-projects Bootstrapper
class:
TriggerConfigureContainer<TArg>(TArg parameter)
This method should be used when root project is doing IoC registration. Its parameter is usually IoC container or container builder. This method will look for ConfigureContainer
method in Bootstrapper
classes and pass in the parameter, allow Bootstrapper
classes to register child projects' dependencies to IoC container
TriggerConfigure(Func<Type, object> serviceLocator = null)
This method should be used when it's the right time to do any non-IoC configuration/initialization (.e.g. AutoMapper setting up). It can be called with or without serviceLocator
parameter
This method takes Func<Type, object>
as its parameter to allow Configure
method in Bootstrapper
classes to take in any number of dependencies (as long as those dependencies can be resolved using serviceLocator func). It works in the same way with Startup.Configure
in ASP.NET Core
Func<Type, object>
is used here to ensure this library is not dependent on any specific IoC container. Most IoC container should support a method with this signature (.e.g. in Autofac
, it's Resolve()
method)
When it's called without serviceLocator
parameter, it will look for only Configure()
method (without any parameter) in Bootstrapper
classes
Trigger<TArg>(string methodName, TArg parameter)
When this method is called, it will look for methods with specified name in Bootstrapper
classes in sub-projects and invoke those, passing in provided parameter. This method is for any other situation where your project cannot use above 2 methods.
Library Design
Read this part only if you are interested in how I implemented the library
The main logic of the library is very simple and lies entirely in BootstrapperLoader class.
It relies on LoaderConfig to hold all necessary information to load and trigger bootstrapper classes (.e.g. bootstrapper class name, what methods to look for, etc)
During initialization, it looks for assemblies with Bootstrapper classes, creates instances of those bootstrapper classes and stores in a list.
When Trigger() method is called, it looks for method with specified name in stored list of boostrapper classes and invoke those:
public void Trigger<TArg>(string methodName, TArg parameter)
{
Bootstrappers.ForEach(bootstrapper =>
ExecuteIfNotNull(
bootstrapper.GetType()
.GetMethod(methodName, MethodBindingFlags, null, new[] {typeof(TArg)}, null),
methodInfo => methodInfo.Invoke(bootstrapper, new object[] { parameter }))
);
}
I need to write a custom method ExecuteIfNotNull() instead of using C# 6 safe navigation operator because I want to target my library to .NET 4.5.2 for wider target audience.
TriggerConfigureContainer() is similar. Config.ConfigureContainerMethods is a dictionary with key is the method name to look for and value is a Func<bool> to decide when to invoke that method
public void TriggerConfigureContainer<TArg>(TArg parameter)
{
Bootstrappers.ForEach(bootstrapper =>
{
Config.ConfigureContainerMethods
.Where(c => c.Value())
.ToList()
.ForEach(methodConfiguration =>
ExecuteIfNotNull(
bootstrapper.GetType().GetMethod(methodConfiguration.Key, MethodBindingFlags, null, new[] {typeof(TArg)}, null),
methodInfo => methodInfo.Invoke(bootstrapper, new object[] { parameter }))
);
});
}
Things are a bit more interesting when TriggerConfigure() is called:
public void TriggerConfigure(Func<Type, object> serviceLocator = null)
{
Bootstrappers.ForEach(bootstrapper =>
{
Config.ConfigureMethods
.Where(c => c.Value())
.ToList()
.ForEach(methodConfiguration =>
ExecuteIfNotNull(
GetMethodInfoByName(bootstrapper.GetType(), methodConfiguration.Key, serviceLocator),
methodInfo => methodInfo.InvokeWithDynamicallyResolvedParameters(bootstrapper, serviceLocator))
);
});
}
First, it needs to evaluate condition for Configure() conditional call and only selects methods that condition evaluates to true. Second, I have an extension method InvokeWithDynamicallyResolvedParameters() for invoking these methods:
public static void InvokeWithDynamicallyResolvedParameters(this MethodInfo configureMethod, object bootstrapper, Func<Type, object> serviceLocator)
{
var parameterInfos = configureMethod.GetParameters();
var parameters = new object[parameterInfos.Length];
for (var index = 0; index < parameterInfos.Length; index++)
{
var parameterInfo = parameterInfos[index];
try
{
parameters[index] = serviceLocator(parameterInfo.ParameterType);
}
catch (Exception ex)
{
}
}
configureMethod.Invoke(bootstrapper, parameters);
}
What this method does is just to iterate through the list of parameters for Bootstrapper Configure() method and tries to resolve those parameters using provided serviceLocator. If you take a look at ASP.NET Core source code, you will find this almost exactly the same as the logic ASP.NET Core used to trigger Startup.Configure(). I would not be able to figure out how to do this initially, so I had to take a look at ASP.NET Core implementation and “borrowed” the idea.
The remaining classes in the library are used to provide Fluent API configuration for loader builder. The only purpose of loader builder is to let client specify different options for LoaderConfig and passes it to BootstrapperLoader in the end.
When an instance of LoaderBuilder is created, it also creates an instance of LoaderConfig class. And throughout the process of builder configuration, LoaderConfig instance is maintained among LoaderBuilder, ForClassSyntax and MethodsSyntax. When client calls LoaderBuilder.Build(), it will create an instance of BootstrapperLoader and passes in finalized LoaderConfig instance:
public BootstrapperLoader Build()
{
var loader = new BootstrapperLoader(Config);
loader.Initialize();
return loader;
}
The implementation of those classes are quite simple. Take a look at the source code if you are interested.
Admittedly, things will be simpler if I don’t provide Fluent API configuration and just let the client configures LoaderConfig directly. However, I still feel it’s much more user-friendly the current way.
Supported Frameworks
The library targets:
- .NET Standard 2.0
- .NET Framework 4.5.2 and higher
- .NET Core 2.0
Quick Notes
- Although the demo application is done using ASP.NET MVC Core. There’s nothing in this library is web-specific and therefore it can be used in any suitable applications
- The demo project has setup for .NET Core 2.0 in master branch and setup for .NET 4.5.2 in net452 branch
Some Final Thoughts
- This library/approach does have some limitation (need to use post-compile script to copy dlls to correct bin folder, as mentioned above), but the advantages that it brings (true separation of layers, clearer project reference, etc) outweigh the limitation in my opinion.
- Also with this approach, dependency injection configuration is no longer centralized in one place but scatter into different projects. Some may see this as downside, but I like the idea of each project being responsible for its own initialization, similar to encapsulation of data in object oriented design.
History
11 Nov 2017: Update articles with changes from version 2.0.0
20 Aug 2017: Upgrade to .NET Core and .NET Standard 2.0, add section on supported frameworks
28 Dec 2016: Initial version