Dependency Injection Containers
The earlier sections of this text explore the principles and patterns defining Dependency Injection. As discussed previously, a DI container is an optional utility that can implement many common infrastructure tasks that would otherwise need to be handled manually when using Pure DI.
Throughout this material, we've kept the discussion container-agnostic, focusing on Pure DI techniques. This shouldn't be interpreted as advice against Pure DI itself; rather, we wanted to demonstrate DI in its purest form, unaffected by any specific container API.
The .NET platform offers several capable DI containers. This section examines when to use such a container versus Pure DI. The remaining chapters cover three open-source DI container options. Each chapter provides detailed coverage of a specific container's API, addressing concerns from earlier sections along with common pitfalls that trouble newcomers. The containers covered are Autofac (Chapter 13), Simple Injector (Chapter 14), and Microsoft.Extensions.DependencyInjection (Chapter 15).
Space limitations prevent us from covering all available containers. We've excluded containers from the first edition except one. Excluded options include Castle Windsor, StructureMap, Spring.NET, Unity, and MEF. For more on these, refer to the first edition (available free). We also considered but excluded Ninject, one of the most popular DI containers, because at the time of writing, no .NET Core compatible version existed.
All containers discussed are open-source projects with active release cycles. Before diving into specific containers, this chapter provides a deeper introduction to what containers are, what they offer, and how to decide between using a DI container or Pure DI.
Due to its market presence, we couldn't exclude Autofac despite dropping other first-edition containers. Autofac is the most widely used DI container for .NET. Chapter 13 covers it in detail. We included Microsoft.Extensions.DependencyInjection (MS.DI) with some hesitation due to its limited feature set. However, we felt it necessary since many developers tend to start with built-in tools before switching to third-party alternatives. Chapter 15 explains what MS.DI can and cannot do.
Each chapter follows a consistent template. Reading the same sentence structure might feel repetitive, but this serves as an advantage when comparing how different containers handle specific features across chapters.
These chapters are meant as starting points. If you haven't settled on a favorite DI container, you can read all three for comparison, or focus on the single chapter that interests you most. While the information here was accurate at publication time, always consult the latest documentation.
Introduction to DI Containers
When I (Mark) was young, my mother and I occasionally made ice cream. This happened rarely because it required significant effort and was difficult to execute properly. Real ice cream based on the French method involves a custard made from sugar, egg yolks, and milk or cream. If heated too much, the mixture curdles. Even if you avoid this, the next stage presents additional challenges. The cream mixture left in the freezer alone will crystallize, so you must stir it periodically until it becomes too stiff to continue. Only then can you have quality homemade ice cream. Despite being slow and labor-intensive, with the right ingredients and equipment, you can use this technique to produce ice cream.
About 35 years later, my mother-in-law makes ice cream far more frequently than my mother and I ever could—not because she enjoys it more, but because she uses technology to assist her. The fundamental technique remains the same, but instead of removing the ice cream from the freezer and stirring regularly, an electric ice cream maker does the work for her.
DI is fundamentally a technique, but you can use technology to make things easier. In earlier sections, we described DI as a technique. In this section, we introduce technology that supports DI implementation. We call this technology the DI Container.
Definition A DI Container is a software library that provides DI functionality and automates many tasks related to object composition, interception, and lifecycle management. It serves as the engine for resolving and managing object graphs.
In this chapter, we'll explore DI containers conceptually—how they fit into the broader DI landscape—and examine patterns and practices around their usage with concrete examples.
The chapter begins with a general introduction to DI containers, including auto-wiring concepts, followed by sections on various configuration options. While you can read each configuraton option independently, we recommend reading "Configuration as Code" before learning about auto-registration.
The final section differs from others. It focuses on the advantages and disadvantages of using DI containers and helps you determine whether using one benefits your application. We consider this essential reading regardless of your experience level with DI and containers.
The chapter aims to provide a solid understanding of what DI containers are and how they fit with the patterns and principles discussed throughout this text. Consider this chapter an introduction to the upcoming sections, where we'll examine specific containers and their APIs.
Understanding DI Containers
A DI container is a software library that automates object composition, lifecycle management, and interception tasks. While you can write all required infrastructure code using Pure DI, this adds little value to your application. Object composition, on the other hand, is a generic task that can be solved once and reused. This represents what's called a Common Subdomain. Given this, using a general-purpose library makes sense—similar to how you might use a logging library instead of implementing logging yourself, or a data access library instead of building your own.
This section examines how DI containers compose object graphs, with examples illustrating container usage versus Pure DI implementation.
Exploring the Container Resolve API
A DI container is like any other software library. It exposes an API for composing objects, where composing an object graph is a single method call. Containers also require configuration before composing objects.
We'll demonstrate how DI containers resolve object graphs with examples using both Autofac and Simple Injector applied to an ASP.NET Core MVC application.
You can use DI containers to resolve controller instances. All three containers covered in subsequent chapters support this functionality, but we'll show examples with two of them here.
Resolving Controllers with Different DI Containers
Autofac provides a pattern-consistent API. Assuming you have an Autofac container instence, you can resolve a controller by providing the requested type:
var controller = (HomeController)container.Resolve(typeof(HomeController));
You pass typeof(HomeController) to the Resolve method and receive an instance fully populated with appropriate dependencies. The Resolve method is weakly typed, returning a System.Object instance, so you must cast it to a more specific type as shown.
Many DI containers share APIs similar to Autofac. Simple Injector's equivalent code looks nearly identical, even though instances are resolved using the SimpleInjector.Container class:
controller = (HomeController)container.GetInstance(typeof(HomeController));
The only real difference is that Resolve becomes GetInstance. These examples illustrate the general shape of DI containers.
Resolving Object Graphs with DI Containers
DI containers are engines for resolving and managing object graphs. While containers offer more than just resolution capabilities, this remains the core part of any container API. Previous examples showed weakly typed methods for this purpose. With variations in names and signatures, the method typically looks like this:
object Resolve(Type serviceType);
As shown earlier, because the returned instance is System.Object, you typically need to cast it to the expected type before use. For cases where you know at compile time which type you need, many DI containers also provide generic versions:
T Resolve<T>();
This overload uses a type parameter (T) indicating the requested type instead of a method argument. The method returns an instance of T. Most containers throw exceptions when they cannot resolve the requested type.
Warning The Resolve method's signature is powerful and flexible. You can request instances of any type, and your code will still compile. In fact, the Resolve method matches the Service Locator signature. As discussed earlier, you must be careful not to use your DI container as a Service Locator by calling Resolve outside the Composition Root.
Viewing the Resolve method in isolation makes it appear almost magical. From the compiler's perspective, you can request it to resolve arbitrary types. How does the container know how to compose the requested type including all dependencies? It doesn't—you must tell it first using configuration that maps abstractions to concrete types. We'll revisit this in the configuration section.
If container configuration is insufficient to fully compose the requested type, containers typically throw descriptive exceptions. For example, consider the HomeController discussed earlier. It contains a dependency of type IProductService:
public class HomeController : Controller
{
private readonly IProductService productService;
public HomeController(IProductService productService)
{
this.productService = productService;
}
...
}
With incomplete configuration, Simple Injector provides exemplary exception messages:
The constructor of type HomeController contains a parameter named productService of type IProductService that is not registered. Ensure IProductService is registered or change HomeController's constructor.
The container couldn't resolve HomeController because it contains a constructor parameter of type IProductService, but Simple Injector wasn't told which implementation to return when IProductService is requested. With correct configuration, containers can resolve complex object graphs based on requested types, providing detailed information about missing pieces when configuration is incomplete.
Auto-Wiring
DI containers thrive on static information compiled into classes. Using reflection, they can analyze requested classes and determine their dependencies.
Since constructor injection is the preferred way to apply DI, all DI containers inherently understand constructor injection. They compose object graphs by combining their configuration with information extracted from class type metadata. This is called auto-wiring.
Definition Auto-wiring is the capability to automatically compose object graphs by leveraging type information provided by the compiler and Common Language Runtime (CLR), based on mappings between abstractions and concrete types.
Most DI containers also understand property injection, though some require explicit enabling. Given the drawbacks of property injection discussed earlier, this is beneficial. Most DI containers follow a generalized algorithm when auto-wiring object graphs.
Note Most DI containers implement optimizations to make successive requests execute faster. How these optimizations work varies between containers. As mentioned earlier, DI containers typically don't cause noticeable performance overhead for typical applications. I/O is usually the primary bottleneck for general applications, and optimizing I/O typically yields more benefits than optimizing object composition.
Configuration is essential for containers. The core of configuration is a list of mappings between abstractions and their concrete class representations. An example will clarify this theoretical concept.
Example: Building a Simple Auto-Wiring Container
To demonstrate how auto-wiring works and show that DI containers aren't magical, let's examine a simple container implementation capable of constructing complex object graphs using auto-wiring.
Warning The following code is marked as problematic. While you can use this code to explore the concept, never use it in actual applications. As we'll detail later, you should use Pure DI or an established, well-tested DI container. This example serves educational purposes only—do not use this in production!
This listing shows a simple container implementation. It lacks lifecycle management, interception, and many other critical features. Auto-wiring is the only supported capability.
A simple auto-wiring container (bad code)
public class SimpleContainer
{
private readonly Dictionary<Type, Func<object>> mappings =
new Dictionary<Type, Func<object>>();
public void Register(Type serviceType, Type componentType)
{
this.mappings[serviceType] =
() => this.Instantiate(componentType);
}
public void Register(Type serviceType, Func<object> factory)
{
this.mappings[serviceType] = factory;
}
public object Resolve(Type type)
{
if (this.mappings.ContainsKey(type))
{
return this.mappings[type]();
}
throw new InvalidOperationException(
"No registration for " + type);
}
private object Instantiate(Type componentType)
{
var constructor = componentType.GetConstructors()[0];
var dependencies =
from param in constructor.GetParameters()
select this.Resolve(param.ParameterType);
return Activator.CreateInstance(
componentType, dependencies.ToArray());
}
}
The SimpleContainer maintains a collection of registrations. Register creates mappings between abstractions (service types) and component types. Abstractions serve as dictionary keys, with values being Func<object> delegates that construct new instances implementing the abstraction. Register tells the container which component to create for a given service type—you specify what to create, not how.
Register adds the service type mapping to the internal dictionary. Optionally, you can provide a Func<T> delegate directly, bypassing auto-wiring. The Resolve method allows resolving complete object graphs. It retrieves the Func<T> for the requested serviceType from the dictionary, invokes it, and returns the result. If no registration exists for the requested type, Resolve throws an exception. Finally, Instantiate creates component instances by iterating through constructor parameters and recursively calling the container for each parameter type, then constructing the type itself using reflection (the System.Activator class).
Note The Instantiate method demonstrates auto-wiring: it uses reflection to analyze type information, recursively calls the container for dependencies, and creates the type itself.
Configure a SimpleContainer instance to compose any object graph. Consider the HomeController from earlier chapters. Instead of manually composing the object graph as shown previously, you can register the five required components with the SimpleContainer by mapping them to their corresponding abstractions.
The following table lists these mappings:
Type mappings supporting HomeController auto-wiring
| Abstraction | Concrete Type |
|---|---|
| HomeController | HomeController |
| IProductService | ProductService |
| IProductRepository | SqlProductRepository |
| CommerceContext | CommerceContext |
| IUserContext | AspNetUserContextAdapter |
The following code demonstrates using the SimpleContainer's Register method to add the required mappings:
Registering HomeController with SimpleContainer
var container = new SimpleContainer();
container.Register(
typeof(IUserContext),
typeof(AspNetUserContextAdapter));
container.Register(
typeof(IProductRepository),
typeof(SqlProductRepository));
container.Register(
typeof(IProductService),
typeof(ProductService));
container.Register(
typeof(HomeController),
typeof(HomeController));
container.Register(
typeof(CommerceContext),
() => new CommerceContext(connectionString));
Note For CommerceContext, you can manually create the context instead of using auto-wiring. Manual wiring is necessary because CommerceContext contains a connectionString parameter, which is a primitive string type.
These registrations cover all components needed to compose the HomeController object graph. Now you can use the configured SimpleContainer to create a new HomeController:
Resolving HomeController with SimpleContainer
object controller = container.Resolve(typeof(HomeController));
When Resolve is called to request a new HomeController type, the container recursively invokes itself until all required dependencies are resolved. Then it creates a new HomeController instance, supplying the resolved dependencies to its constructor.
When the container receives a request for HomeController, it first looks up the type in its configuration. HomeController is a concrete class mapped to itself. The container uses reflection to examine HomeController's unique constructor with this signature:
public HomeController(IProductService productService)
Since this constructor requires a parameter, the process repeats for the IProductService parameter. The container looks up IProductService in its configuration and finds it maps to ProductService. ProductService's constructor has this signature:
public ProductService(IProductRepository repository, IUserContext userContext)
This also requires parameters, so the container processes each one. It starts with IProductRepository, which maps to SqlProductRepository based on configuration. SqlProductRepository's constructor requires a CommerceContext:
public SqlProductRepository(CommerceContext context)
The container needs to resolve CommerceContext for SqlProductRepository's constructor. CommerceContext was registered with this delegate:
() => new CommerceContext(connectionString)
The container invokes this delegate, creating a new CommerceContext without auto-wiring.
Important When you start using DI containers, you don't need to completely abandon manually wired object graphs.
Now the container has CommerceContext, it can call SqlProductRepository's constructor. It has successfully processed the repository parameter but must also handle the userContext parameter. Based on configuration, IUserContext maps to AspNetUserContextAdapter:
public AspNetUserContextAdapter()
Since AspNetUserContextAdapter has a parameterless constructor, it can be created without resolving dependencies. The container passes the new instance to ProductService's constructor along with the SqlProductRepository, then calls ProductService's constructor through reflection. Finally, it passes the new ProductService instance to HomeController's constructor and returns the HomeController instance.
The advantage of auto-wiring over Pure DI is that when component constructors change, Pure DI requires corresponding changes in the Composition Root. Auto-wiring makes the Composition Root more resilient to such changes.
For example, if you need to add a CommerceContext dependency to AspNetUserContextAdapter, Pure DI requires updating the Composition Root:
Modified Composition Root for updated AspNetUserContextAdapter
new HomeController(
new ProductService(
new SqlProductRepository(
new CommerceContext(connectionString)),
new AspNetUserContextAdapter(
new CommerceContext(connectionString))));
With auto-wiring, no Composition Root changes are needed. AspNetUserContextAdapter is auto-wired, and since the new CommerceContext dependency is registered, the container satisfies the new constructor parameter automatically.
Important While auto-wiring reduces Composition Root maintenance, this doesn't mean you should always prefer DI containers over Pure DI. The later section on when to use each approach provides detailed guidance.
This demonstrates how auto-wiring works. Real DI containers also handle lifecycle management and potentially property injection and other specialized creation requirements.
Warning The SimpleContainer throws StackOverflowException when circular dependencies exist in the object graph. StackOverflowExceptions are problematic because they crash applications, making debugging difficult. This is one of many reasons you should always prefer an established DI container over implementing your own. Most modern DI containers detect cycles without terminating the process.
The key takeaway is that constructor injection statically advertises a class's dependency requirements, and DI containers use this information to auto-wire complex object graphs. Containers must be configured before composing object graphs, and component registration can be done in multiple ways.
Configuring DI Containers
While most runtime operations involve calling Resolve, expect to spend most of your time working with the container's configuration API. After all, resolving object graphs is a single method call.
DI containers typically support two or three general configuration approaches. Some lack file-based configuration support, others lack auto-registration, and some provide everything including configuration as code. Most allow mixing several approaches in the same application. The section on mixing approaches discusses why you might combine methods.
These configuration options have different characteristics making them suitable for different situations. Configuration files and configuration as code tend to be explicit, requiring you to register each component individually. Auto-registration is more implicit, using conventions to register groups of components with a single rule.
With configuration as code, container configuration compiles into assemblies, while file-based configuration enables late binding where you can change configuration without recompiling. Auto-registration sits in the middle—you can scan assemblies known at compile time or predefined folders containing assemblies unknown at compile time.
Configuration options comparison
| Approach | Description | Advantages | Disadvantages |
|---|---|---|---|
| Configuration Files | Mappings specified in configuration files (typically XML or JSON) | Supports swapping without recompilation | No compile-time checking Verbose and fragile |
| Configuration as Code | Code explicitly defines mappings | Compile-time checking Greater control | No swapping without recompilation |
| Auto-Registration | Rules locate suitable components using reflection and build mappings | Supports swapping without recompilation Less effort required Helps enforce conventions for consistency | No compile-time checking Less control Can seem abstract initially |
Historically, DI containers started with configuration files, which explains why older libraries still support them. However, this approach has been deemphasized in favor of more conventional methods. Recently developed containers like Simple Injector and Microsoft.Extensions.DependencyInjection lack built-in file-based configuration support.
Important Once you start resolving object graphs, you shouldn't reconfigure the container. This relates to the Register Resolve Release pattern.
Despite being the most modern option, auto-registration isn't the most obvious starting point. Due to its implicitness, it seems more abstract than explicit options, so we'll present each option in historical order, starting with configuration files.
Configuring Containers with Files
When DI containers first appeared in the early 2000s, they all used XML for configuration—that was the standard approach at the time. Experience with XML as a configuration mechanism later revealed it was rarely the best choice.
XML tends to be verbose and fragile. When configuring DI containers with XML, you identify classes and interfaces, but there's no compiler support to warn you if you misspell something. Even with correct class names, there's no guarantee the required assembly will be in the application's probing path.
Note In recent years, JSON has become popular for expressing configuration. The format is cleaner and easier to read than XML, but it shares the same characteristics—equally fragile and verbose.
Additionally, XML has limited expressive power compared to regular code. This sometimes makes it difficult or impossible to express certain configurations in files that would be straightforward in code. For instance, in earlier examples, you used lambda expressions to register CommerceContext. Such expressions cannot be represented in XML or JSON.
The advantage of configuration files is that you can change application behavior without recompiling. This is valuable when developing software delivered to thousands of customers, providing them a way to customize the application. However, for internal applications or websites where you control the deployment environment, recompiling and redeploying is usually easier when behavior changes are needed.
Important Configuration files, like configuration as code and auto-registration, are part of the Composition Root. Using configuration files doesn't make the Composition Root smaller—it just moves it. Use configuration files only for parts of DI configuration requiring late binding. For all other configuration parts, use configuration as code or auto-registration.
DI containers typically load files by pointing them to a specific configuration file. The following example uses Autofac.
Note Since Autofac is the only container covered in this book with built-in file-based configuration support, using it as an example makes sense.
In this example, you configure the same classes from the earlier auto-wiring section. Most of the task involves applying the same mappings, plus additional configuration to support composing the HomeController class:
Configuring Autofac with JSON configuration file
{
"defaultAssembly": "Commerce.Web",
"components": [
{
"services": [{
"type":
"Commerce.Domain.IUserContext, Commerce.Domain"
}],
"type":
"Commerce.Web.AspNetUserContextAdapter"
},
{
"services": [{
"type": "Commerce.Domain.IProductRepository, Commerce.Domain"
}],
"type": "Commerce.SqlDataAccess.SqlProductRepository, Commerce.SqlDataAccess"
},
{
"services": [{
"type": "Commerce.Domain.IProductService, Commerce.Domain"
}],
"type":
"Commerce.Domain.ProductService, Commerce.Domain"
},
{
"type": "Commerce.Web.Controllers.HomeController"
},
{
"type": "Commerce.SqlDataAccess.CommerceContext,Commerce.SqlDataAccess",
"parameters": {
"connectionString":
"Server=.;Database=MaryCommerce;Trusted_Connection=True;"
}
}]
}
If you don't specify an assembly-qualified type name in type or interface references, defaultAssembly is assumed as the default assembly. Simple mappings require complete type names including namespaces and assembly names. Since AspNetUserContextAdapter omits the assembly name, Autofac looks for it in the Commerce.Web assembly defined as defaultAssembly.
From this example, JSON configuration tends to be very verbose. Simple mappings like IUserContext to AspNetUserContextAdapter require substantial text with brackets and fully-qualified type names.
CommerceContext takes a connection string as input, so you specify how to find this value by adding parameters to the mapping with their parameter names. Load the configuration into the container with this code:
Loading configuration file with Autofac
var builder = new Autofac.ContainerBuilder();
IConfigurationRoot configuration =
new ConfigurationBuilder()
.AddJsonFile("autofac.json")
.Build();
builder.RegisterModule(
new Autofac.Configuration.ConfigurationModule(
configuration));
Autofac is the only container covered that supports file-based configuration, but other containers not covered here continue supporting files. Each container's exact architecture differs, but the genarel structure is similar—you need to map abstractions to implementations.
Warning As your application grows in size and complexity, your configuration files grow too. They can become a real stumbling block because they model coding concepts like classes and parameters but lack compiler support, debugging options, and similar benefits. Configuration files are fragile and opaque to errors—use this approach only when late binding is necessary.
Configuration Files Don't Scale
I (Steven) once worked with a large client maintaining a product containing over a century of code. DI was used extensively, which was an absolute advantage. However, for object composition, they used Spring.NET as their DI container, which at the time only supported XML configuration files. Worse, their Spring.NET version didn't support auto-wiring. This required not just explicit definitions of every mapping in large XML files, but also specifying every constructor dependency. These Spring.NET XML configuration files were worked on by dozens of teams, and they were not just verbose, fragile, and maintenance-heavy, but also regularly caused merge conflicts.
Due to verbosity, fragility, lack of compiler support, and performance issues with these XML files, significant developer time was wasted daily. If they had chosen Pure DI from the start, they would have been better off. Pure DI itself wouldn't have solved the merge conflicts, but at least the compiler would have helped catch most errors earlier.
Tip While configuration files might work for small applications or small portions of an application, they don't scale. Avoid using configuration files as the default approach for DI configuration. As discussed later, use Pure DI or auto-registration.
Don't let lack of file-based configuration support heavily influence your choice of DI container. As mentioned, only truly late-binding components should be defined in configuration files, which won't be many. Even without built-in support, you can load types from configuration files with a few simple statements.
Configuration as code is similar in granularity and concept to configuration files but uses code instead of XML or JSON.
Configuring Containers Using Code
The simplest approach to composing an application might be hard-coding object graph construction. This seems contrary to DI's spirit since it determines concrete implementations for all abstractions at compile time. However, when done in the Composition Root, it only violates one benefit from the list—late binding.
When dependencies are hard-coded, you lose the benefits of late binding, but as mentioned earlier, this might not apply to all types of applications. If deploying in controlled environments with limited instances, recompiling and redeploying is easier when you need to swap modules.
Note I often think people are too eager to define configuration files. Programming languages typically provide an easy and powerful configuration mechanism.
With configuration as code, you explicitly declare the same discrete mappings as with configuration files—using code instead of XML or JSON.
Definition In the context of DI containers, configuration as code allows storing container configuration as source code. Each mapping between abstraction classes and specific implementations is explicitly represented directly in code.
All modern DI containers fully support configuration as code as the successor to configuration files. Most use it as their default mechanism, with file-based configuration as an optional feature. Some provide no file-based configuration support at all. APIs supporting configuration as code vary between containers, but the overall goal remains defining discrete mappings between abstraction classes and concrete types.
Tip Unless you need late binding, configuration as code is preferable to configuration files. The compiler can help, and Visual Studio's build system automatically copies required assemblies to the output folder. If you do need late binding, only use configuration files for parts requiring it, which typically represents a small portion of the overall application.
Let's see how to configure the example application using configuration as code with Microsoft.Extensions.DependencyInjection. This example configures the same mappings as the file-based example but more compactly:
Configuring Microsoft.Extensions.DependencyInjection with code
var services = new ServiceCollection();
services.AddSingleton<IUserContext, AspNetUserContextAdapter>();
services.AddTransient<IProductRepository, SqlProductRepository>();
services.AddTransient<IProductService, ProductService>();
services.AddTransient<HomeController>();
services.AddScoped<CommerceContext>(
p => new CommerceContext(connectionString));
Microsoft's ServiceCollection is equivalent to Autofac's ContainerBuilder, defining mappings between abstractions and implementations. AddTransient, AddScoped, and AddSingleton methods add auto-wired mappings between abstraction classes and concrete types with specific lifestyles. These generic methods result in more compact code with the added benefit of some compile-time checking. When concrete types map to themselves, there's a convenient overload taking just the concrete type as a generic type parameter. And like the SimpleContainer example earlier, this container's API includes an overload allowing mapping abstractions to Func<T> delegates.
Note This conceptually mirrors the earlier SimpleContainer example where we built the auto-wiring proof of concept.
In this example, we freely use three common lifestyles—Singleton, Transient, and Scoped—to demonstrate component registration. Subsequent chapters detail configuring lifestyles for each container.
Compare this code with the file-based configuration and notice its compactness—despite doing exactly the same thing. A simple mapping like IProductService to ProductService is represented with a single method call.
Configuration as code is not only much more compact than file-based configuration but also has compiler support. Type parameters used in the code are checked by the compiler. Generics can go further—using generic type constraints like those in the Microsoft API makes the compiler verify that provided concrete types match abstractions. If conversion isn't possible, the code won't compile.
Despite being safe and easy to use, configuration as code requires more maintenance than desired. Every time you add a new type to your application, you must remember to register it, and many registrations end up similar. Auto-registration solves this problem.
Configuring Containers by Convention Using Auto-Registration
Considering registrations like those in the previous example, having several lines of code in your project might be perfectly acceptable initially. However, as projects evolve, so does the number of registrations needed to set up DI containers. Over time, you might see many similar registrations following common patterns:
Repetition in registrations using configuration as code
services.AddTransient<IProductRepository, SqlProductRepository>();
services.AddTransient<ICustomerRepository, SqlCustomerRepository>();
services.AddTransient<IOrderRepository, SqlOrderRepository>();
services.AddTransient<IShipmentRepository, SqlShipmentRepository>();
services.AddTransient<IImageRepository, SqlImageRepository>();
services.AddTransient<IProductService, ProductService>();
services.AddTransient<ICustomerService, CustomerService>();
services.AddTransient<IOrderService, OrderService>();
services.AddTransient<IShipmentService, ShipmentService>();
services.AddTransient<IImageService, ImageService>();
Repeatedly writing such registrations violates the DRY principle. Infrastructure code becomes less productive without adding significant value. If you could auto-register components, you could save time and reduce errors, assuming those components follow some convention. Many DI containers provide auto-registration functionality allowing you to define conventions and apply them to configuration.
Definition Auto-registration is the capability to automatically register components in a container by scanning one or more assemblies for implementations of desired abstractions, based on some convention. Auto-registration is sometimes called batch registration or assembly scanning.
Convention over Configuration
Convention over configuration is an increasingly popular architectural model. Instead of writing and maintaining extensive configuration code, you agree on conventions affecting the codebase. ASP.NET Core MVC's way of finding controllers by controller name exemplifies a simple convention:
- Request a controller named Home.
- The default controller factory searches a well-known list of namespaces for a class named HomeController. If found, it's a match.
- The default controller factory forwards the class type to the controller activator, which constructs the controller instance.
The convention here is that controllers must be named [ControllerName]Controller.
Conventions can apply beyond ASP.NET Core MVC controllers. The more conventions you add, the more parts of container configuration become automated.
Tip Convention over configuration offers more benefits than just DI configuration support. As long as you follow conventions, things work automatically, making code more consistent.
In practice, you might combine auto-registration with configuration as code or configuration files because you likely can't make every component conform to meaningful conventions. However, the more you can shift your codebase toward conventions, the more maintainable it becomes.
Autofac supports auto-registration, but using Simple Injector to demonstrate convention-based configuration seems more instructive. Since we want to keep examples within containers discussed in this book, and Microsoft.Extensions.DependencyInjection lacks auto-registration capabilities, Simple Injector illustrates this concept well.
Looking at the earlier registrations, you might agree the various data access component registrations are repetitive. Can we express a convention around them? All five concrete repository types share characteristics:
- They all reside in the same assembly.
- Each concrete class name ends with Repository.
- Each implements one interface.
A suitable convention might scan the relevant assembly and register all classes matching this convention. Simple Injector's auto-registration API focuses on registering groups of types sharing the same interface, which might seem limiting. However, this apparent limitation often becomes an advantage—you can define custom LINQ queries atop .NET's reflection API, providing more flexibility without learning another API (assuming you're familiar with LINQ and .NET reflection). The following demonstrates this convention using a LINQ query:
Scanning repositories with Simple Injector using convention
var assembly = typeof(SqlProductRepository).Assembly;
var repositoryTypes =
from type in assembly.GetTypes()
where !type.IsAbstract
where type.Name.EndsWith("Repository")
select type;
foreach (Type type in repositoryTypes)
{
container.Register(
type.GetInterfaces().Single(), type);
}
Each class passing through the where filters gets registered against its interface. For example, since SqlProductRepository's interface is IProductRepository, the result is a mapping from IProductRepository to SqlProductRepository.
This specific convention scans the assembly containing data access components. The easiest way to get a reference to that assembly is selecting a representative type like SqlProductRepository and getting its assembly. You could also choose other classes or find assemblies by name.
Note Using Microsoft.Extensions.DependencyInjection, the convention code is nearly identical. Only the foreach body differs since that's where container API calls happen.
Compare this convention to the four registrations shown earlier—you might think the benefits seem trivial. With only four data access components in the current example, the code statement count actually increases with the convention. However, this convention scales much better. Once written, it handles hundreds of components without additional work.
You could also handle other mappings from earlier examples with conventions, but doing so offers less value. For instance, you could register all services with this convention:
var assembly = typeof(ProductService).Assembly;
var serviceTypes =
from type in assembly.GetTypes()
where !type.IsAbstract
where type.Name.EndsWith("Service")
select type;
foreach (Type type in serviceTypes)
{
container.Register(type.GetInterfaces().Single(), type);
}
This convention scans the identified assembly for all concrete classes ending with Service and registers each type based on its implemented interface. This effectively registers ProductService on the IProductService interface, but without other matching services, you gain nothing. Conventions only become worthwhile as more services are added.
Using LINQ to manually define conventions makes sense for types deriving from their own interfaces like repositories. However, this strategy starts breaking down when you begin registering types based on generic interfaces—as discussed extensively elsewhere in this text—querying generic types through reflection is often unpleasant.
Therefore, Simple Injector's auto-registration API focuses on registering types based on generic abstractions like the ICommandHandler<TCommand> interface. Simple Injector allows registering all ICommandHandler<TCommand> implementations in a single line.
Auto-registration implementation based on generic abstractions
Assembly assembly = typeof(AdjustInventoryHandler).Assembly;
container.Register(typeof(ICommandHandler<>), assembly);
Note ICommandHandler<> is C# syntax for specifying an open generic version by omitting the TCommand type parameter.
By providing an assembly list to one of its overloads, Simple Injector iterates through those assemblies finding any non-generic, concrete types implementing ICommandHandler<TCommand>, registering each type with its specific ICommandHandler<TCommand> interface. This populates the generic type parameter TCommand with the actual type.
Definition A generic type with filled type parameters (like ICommandHandler<AdjustInventory>) is called a closed generic. Similarly, when you only have the generic type definition itself (like ICommandHandler<TCommand>), such types are called open generics.
In an application with four ICommandHandler<TCommand> implementations, the previous API call is equivalent to this configuration as code list:
Registering implementations using configuration as code
container.Register(typeof(ICommandHandler<AdjustInventory>),
typeof(AdjustInventoryHandler));
container.Register(typeof(ICommandHandler<UpdateProductReviewTotals>),
typeof(UpdateProductReviewTotalsHandler));
container.Register(typeof(ICommandHandler<UpdateHasDiscountsApplied>),
typeof(UpdateHasDiscountsAppliedHandler));
container.Register(typeof(ICommandHandler<UpdateHasTierPricesProperty>),
typeof(UpdateHasTierPricesPropertyHandler));
Iterating through assemblies to find appropriate types isn't the only thing you can accomplish with Simple Injector's auto-registration API. Another powerful feature is registering generic decorators. Without manually composing decorator hierarchies, Simple Injector allows applying decorators using its RegisterDecorator method overload:
Using auto-registration for generic decorators
container.RegisterDecorator(
typeof(ICommandHandler<>),
typeof(AuditingCommandHandlerDecorator<>));
container.RegisterDecorator(
typeof(ICommandHandler<>),
typeof(TransactionCommandHandlerDecorator<>));
container.RegisterDecorator(
typeof(ICommandHandler<>),
typeof(SecureCommandHandlerDecorator<>));
RegisterDecorator takes an open generic service type (ICommandHandler<TCommand>) and an open generic decorator implementation. Using this information, Simple Injector wraps each ICommandHandler<TCommand> resolved by the container.
Simple Injector applies decorators in registration order, meaning the auditing decorator gets wrapped by the transaction decorator, which gets wrapped by the secure decorator. Without this auto-registration form for generic decorators, you'd be forced to separately register each decorator's closed version for every closed ICommandHandler<TCommand> implementation.
Registering generic decorators using configuration as code (bad code)
container.RegisterDecorator(
typeof(ICommandHandler<AdjustInventory>),
typeof(AuditingCommandHandlerDecorator<AdjustInventory>));
container.RegisterDecorator(
typeof(ICommandHandler<AdjustInventory>),
typeof(TransactionCommandHandlerDecorator<AdjustInventory>));
container.RegisterDecorator(
typeof(ICommandHandler<AdjustInventory>),
typeof(SecureCommandHandlerDecorator<AdjustInventory>));
// ... repeated for each ICommandHandler<TCommand> implementation
This code is tedious and error-prone. It also causes exponential growth in the Composition Root.
Tip The most significant disadvantage of auto-registration is losing some control. Every component picked up by auto-registration must be auto-wirable. If specific components need manual wiring, exclude them from auto-registration to prevent errors.
In systems following SOLID principles, you create many small, focused classes that change less often, improving maintainability. Auto-registration prevents the Composition Root from constantly needing updates. This is a powerful technique potentially making DI containers invisible. Once appropriate conventions are in place, you may only need to modify container configuration in rare circumstances.
Mixing Configuration Approaches
You've now seen three different approaches for configuring DI containers:
- Configuration files
- Configuration as code
- Auto-registration
These aren't mutually exclusive. You can combine auto-registration with specific abstraction-to-concrete-type mappings, or mix all three approaches having some auto-registration, some configuration as code, and some configuration files for late binding.
As a rule of thumb, prefer auto-registration as a starting point, supplementing with configuration as code for edge cases. Reserve configuration files for situations requiring implementation changes without recompiling—which happens less often than you might think.
When to Use DI Containers
Earlier sections used only Pure DI as the object composition method. This wasn't just for educational purposes. Pure DI alone can build complete applications.
The configuration section discussed different DI container configuration methods and how auto-registration can increase Composition Root maintainability. However, using DI containers incurs additional costs and drawbacks compared to Pure DI. Most DI containers are open-source and therefore free from a monetary perspective. But since developer time is typically the most expensive part of software development, anything increasing development and maintenance time represents a cost.
This section compares advantages and disadvantages to help you make informed decisions about when to use DI containers versus Pure DI. Let's start with aspects often overlooked when using libraries like DI containers that carry costs and risks.
Using Third-Party Libraries Involves Costs and Risks
When libraries are free monetarily, developers tend to overlook other costs involved. DI containers might be considered stable dependencies, so from a DI perspective, using a container isn't problematic. However, other considerations exist. Like any third-party library, using DI containers involves costs and risks.
The most obvious cost in any library is its learning curve—learning to use a new library takes time. You must learn its API, behavior, quirks, and limitations. When working with a team, most of them must understand how to use the library in one way or another. Having only one developer who knows the tool can save costs short-term, but this practice itself creates project continuity liability.
Library behavior, quirks, and limitations might not perfectly match your needs. Libraries might choose models different from what your software is based on. This often only becomes apparent when learning the library. Applying it to your codebase, you might find yourself implementing workarounds.
Learning costs are difficult to estimate realistically, making it hard to estimate how much money using a new library will save. Cumulative time spent learning a third-party library's API isn't time spent building the application itself, representing actual cost.
Beyond direct costs of learning the library, depending on such libraries involves risks. One risk is developers stopping maintenance and support for the library you're using. When this happens, it imposes additional costs potentially forcing you to switch libraries. You'd again pay learning costs plus additional costs for migration and testing.
Tip Due to these costs and risks, choose libraries carefully for your project. When starting new projects, limiting the number of external libraries your team must familiarize themselves with helps reduce risk.
This sounds like an argument against external libraries, but it isn't. Without external libraries, you'd be reinventing wheels constantly. If not using external libraries means building your own, situations are usually worse. Developers also tend to underestimate time required to write, test, and maintain such software.
However, with DI containers, the situation differs. This is because the alternative to using an external DI container library isn't building your own DI container library—it's applying Pure DI.
Don't Build Your Own DI Container
At first glance, the SimpleContainer code might suggest a DI container can be written in a few lines. While it outlines initial steps, there's an obvious reason it's marked as bad code.
As discussed earlier, the code is a naive implementation missing many critical features. A full-featured DI container should support lifecycle management, interception, auto-registration, and cyclic dependency detection. It should communicate configuration errors effectively, have well-designed extension points, rely on excellent documentation, and more. This isn't something you accomplish in a few weeks.
Based on experience, such libraries take years to become stable and mature. While this might be a great learning experience for you as a developer, it doesn't help your project or company since your focus should be on creating business value.
This doesn't mean you should never create new open-source libraries like DI containers. Innovation is essential to our industry, and creating new libraries helps. Sometimes we need radical new ideas, which sometimes means building new libraries and frameworks based on those ideas. But be cautious using employer money because the cost often exceeds initial estimates.
As you learned earlier, interaction with DI containers should be limited to the Composition Root. This already reduces risk if replacement becomes necessary. However, even then, replacing a DI container and learning a new API and design principles can be time-consuming.
Pure DI's main advantage is ease of learning. You don't need to learn any DI container's API. While classes still use DI, once you find the Composition Root, it becomes clear what's happening and how object graphs are constructed. With DI containers, new team members might struggle understanding constructed object graphs and finding where class dependencies are implemented, though newer IDEs help.
With Pure DI, this is rarely problematic because object graph construction is hard-coded in the Composition Root. Beyond ease of learning, Pure DI provides shorter feedback cycles when object composition errors occur.
Pure DI Provides Faster Feedback
DI container techniques like auto-wiring and auto-registration depend on reflection. This means at runtime, DI containers use reflection to analyze constructor parameters and even query full assemblies for types based on conventions to compose complete object graphs. Therefore, configuration errors are only detected at runtime when resolving object graphs. DI containers assume the compiler's code validation role.
Important Pure DI has a frequently overlooked significant advantage: it's strongly typed. This enables the compiler to provide correctness feedback—the fastest feedback you can get.
When the Composition Root is structured well, separating singleton creation from scoped instance creation, the compiler can detect mandatory dependencies as discussed in earlier sections.
Pure DI also has the advantage of giving you a clearer picture of your application's object graph structure due to its strong typing. This is immediately lost when starting to use DI containers.
Strong typing works both ways. This also means every time you refactor constructors, you break the Composition Root. If you share libraries (domain models, utilities, data access components) between applications, you might need to maintain multiple Composition Roots. How burdensome this is depends on how often you refactor constructors, but we've seen projects where this happens several times daily. With multiple developers working on a project, this easily causes merge conflicts requiring time to resolve.
While Pure DI provides fast feedback from the compiler, it has limits on how much it can validate. Constructor changes and mandatory dependencies can be reported, but it can't detect situations like:
- Constructor calls failing due to exceptions thrown from within constructor bodies (such as failing guard clauses)
- Disposing disposable components when they go out of scope
- Accidentally recreating classes that should be Singleton or Scoped in different parts of the Composition Root
With Pure DI, Composition Root size grows linearly with application size. When applications are small, their Composition Roots are also small. This keeps them clean and manageable, and the previously listed deficiencies are easily spotted. However, as Composition Roots grow, such deficiencies become easier to miss.
DI containers can mitigate this. Most DI containers automatically detect disposable components on your behalf and might detect common pitfalls like violating lifetime expectations.
Conclusion: When to Use DI Containers
If you use a DI container's configuration as code feature and explicitly register each component using the container's API, you lose fast feedback from strong typing. However, maintenance burden potentially decreases due to auto-wiring. You still need to register each new class, which is linear growth, and you and your team must learn that container's specific API. Even if you're already familiar with its API, there's still risk of someday needing to replace it.
Ultimately, if you use a DI container sufficiently complexly, you can define a set of conventions using auto-registration. These conventions define rules your code should follow, and as long as you follow them, things work. The container fades into the background, and you barely need to touch it.
Important Using conventions over configuration with auto-registration can reduce Composition Root maintenance to nearly zero.
Auto-registration requires learning time and is weakly typed, but when done correctly, it lets you focus on code adding value rather than infrastructure. Another benefit is creating positive feedback mechanisms forcing teams to produce code consistent with conventions.
Pure DI might be valuable because it's simple, while DI containers might be valuable or pointless depending on how they're used. If used sufficiently complexly through auto-registration, DI containers offer the best value proposition.
As discussed, no available approach is mutually exclusive. While you might find a Composition Root containing mixtures of all configuration styles, the Composition Root should either be Pure DI with a few late-binding types, or a limited amount of auto-registration. Configuration as code with Pure DI and configuration files. A Composition Root built entirely around configuration as code makes little sense and should be avoided.
The question becomes: when should you choose Pure DI, and when should you use auto-registration? Unfortunately, we can't give exact numbers. It depends on project scale, your and your team's experience with DI containers, and risk calculation.
Generally, use Pure DI for smaller Composition Roots, switching to auto-registration when maintaining such Composition Roots becomes problematic. Large applications with many classes capturable by conventions benefit from auto-registration.
The "Automagical" Factor
I (Mark) once worked with a client where I applied convention-based auto-registration to their codebase. Other developers weren't comfortable with this because they found it too magical. They fully embraced DI and practiced TDD but weren't keen on using a DI container since they weren't familiar with its API.
In many cases, these conventions worked as advertised. When developers introduced new classes or interfaces, the DI container discovered new types and configured them correctly. However, developers sometimes implemented functionality in ways conventions couldn't anticipate. When this happened, adjusting conventions was necessary.
Other developers didn't understand—and weren't interested in learning—how to use the DI container's API, so whenever changes were needed, I had to implement them. I became a critical resource and occasional bottleneck. When I left that project, I hoped the remaining team would eliminate the DI container and replace it with Pure DI. Returning a year later, learning that's exactly what they did didn't surprise me. I can't say I blamed them.
We won't tell you which DI container to choose. Selecting a DI container involves more than technical evaluation. You must evaluate whether licensing models are acceptable, whether you trust the people or organization developing and maintaining it, whether it fits your organization's IT strategy, and more. Your search for the right DI container shouldn't be limited to containers listed here either. Many excellent DI containers for .NET exist.
When used correctly, DI containers can be valuable tools. The most important point to understand is that using DI never depends on using a DI container. Applications can consist of many loosely coupled classes and modules, none of which know about any container. The most effective way to ensure application code doesn't recognize any DI container is limiting its use to the Composition Root. This prevents inadvertently applying the Service Locator anti-pattern by restricting the container to a small code isolation area.
Used this way, a DI container becomes an engine responsible for handling parts of application infrastructure. It composes object graphs based on its configuration. This is especially beneficial when adopting convention over configuration. When implemented well, it can compose object graphs while you focus on implementing new features. The container automatically discovers new classes following established conventions and makes them available to consumers. The remaining chapters cover Autofac (Chapter 13), Simple Injector (Chapter 14), and Microsoft.Extensions.DependencyInjection (Chapter 15).
Summary
- A DI container is a library providing DI functionality. It serves as the engine for resolving and managing object graphs.
- DI never depends on DI containers. DI containers are useful but optional tools.
- Auto-wiring automatically composes object graphs by leveraging type information from the compiler and CLR, based on mappings between abstractions and concrete types.
- Constructor injection statically advertises a class's dependency requirements, and DI containers use this information to auto-wire complex object graphs.
- Auto-wiring makes the Composition Root more resilient to changes.
- When starting with DI containers, you don't need to completely abandon manually wired object graphs. You can use manual wiring where convenient.
- Three configuration styles exist for DI containers: configuration files, configuration as code, and auto-registration.
- Configuration files, like code and auto-registration, are part of the Composition Root. Using configuration files doesn't make the Composition Root smaller—it just moves it.
- Configuration files grow with application size and complexity. They're fragile and opaque to errors—use them only when late binding is necessary.
- Don't let lack of file-based configuration support heavily influence DI container selection. You can load types from configuration files with simple statements.
- Configuration as code stores container configuration as source code with explicit mappings. Prefer this over configuration files unless you need late binding.
- Convention over configuration applies conventions to your code to simplify registration.
- Auto-registration automatically registers components by scanning assemblies for implementations of desired abstractions—a form of convention over configuration.
- Auto-registration helps avoid constantly updating the Composition Root, making it preferable over configuration as code.
- Using external libraries like DI containers involves costs and risks: learning new APIs, risk of library abandonment, and more.
- Avoid building your own DI container. Use an existing, well-tested, freely available container or Pure DI. Creating and maintaining such libraries requires significant effort not spent creating business value.
- Pure DI's greatest advantage is strong typing, enabling the compiler to provide the fastest correctness feedback.
- Use Pure DI for smaller Composition Roots, switching to auto-registration when maintaining them becomes problematic. Large applications with many classes capturable by conventions benefit significantly from auto-registration.