The Perils of Mixing Dependency Injection Lifetimes
Dependency Injection (DI) is a powerful tool in modern software development. It promotes loose coupling, enhances testability, and simplifies the management of dependencies. However, with great power comes great responsibility. One of the most common pitfalls developers face is mixing dependency injection lifetimes improperly when registering services. This seemingly minor oversight can lead to subtle, hard-to-diagnose bugs that can wreak havoc on your application.
Understanding DI Lifetimes
Before diving into the issues, it’s crucial to understand the three most commonly offered DI lifetimes present in any DI library (maybe by different names):
-
Transient: Services are created each time they are requested.
-
Scoped: Services are created once per request or scope.
-
Singleton: Services are created once and shared across the entire application.
The Problem: Mixing Lifetimes
Mixing lifetimes happens when you register a service with a lifetime different than its dependencies. For example: registering a Singleton service that depends on a Transient.
At first glance, this might not seem like a big deal. After all, the DI container handles the creation and injection of dependencies. However, this can lead to serious issues.
The Dangers
-
Captive Dependency: When a Singleton service depends on a Scoped or Transient service, the shorter-lived service is effectively "captured" by the singleton. This means that the singleton service will hold onto the instance of the shorter-lived service for its entire lifetime. In the case of a Scoped service, this breaks the expectation that a new instance will be created for each request. For a Transient service, this could lead to unintended state sharing across requests or users.
-
Memory Leaks: When a Singleton holds onto a Scoped or Transient service, it can prevent those objects from being garbage collected. This can lead to memory leaks, especially in long-running applications where the singleton service is never disposed of.
-
Inconsistent State: Imagine a Scoped service that holds state related to the current request. If this service is captured by a Singleton, it will continue to hold onto the state from the first request it was involved in. Future requests will then incorrectly operate on the stale data, leading to inconsistent and unpredictable behavior.
-
Hidden Bugs: These kinds of bugs are notoriously hard to detect because they often manifest under specific conditions or over time. They can lead to intermittent issues that are challenging to reproduce, making them some of the most frustrating bugs to deal with in a production environment.
Let's look at a concrete example:
@Service(Lifetime.Transient)
class ProductValidator {
public errors: string[] = [];
public validate(product: Product) {
if (!product.name) {
this.errors.push('Name missing');
}
}
}
@Service(Lifetime.Singleton)
class ProductService {
constructor (
private readonly validator: ProductValidator,
private readonly repository: ProductRepository,
) {}
public save(product: Product) {
this.validator.validate(product)
if (this.validator.errors) {
throw new ProductValidationError(this.validator.errors);
}
this.repository.save(product);
}
}
Let's break it down. The ProductValidator
service is registered as a Transient service. This makes sense since a call to validate
is mutating its internal state. This service is not meant to be shared or reused. On the other hand ProductService
does not keep any state and it is "safe" to register as a Singleton. While these two services are ok independently, the way they are interacting with each other is not. Can you spot the problem? Let's write a test to show the problem:
it('rejects an invalid product and saves a valid one', () => {
const container = createDependencyInjectionContainer(); // this method creates and registers the services.
const productService = container.get<ProductService>(ProductService);
const product1 = {};
expect(() => productService.save(product1)).toThrowError(ProductValidationError);
const productService2 = container.get<ProductService>(ProductService);
const product2 = { name: 'Book' };
expect(() => productService2.save(product2)).not.toThrow();
});
This test will fail when trying to save product2
because the DI container will return the same ProductService
instance (it was registered as a Singleton) and this singleton has captured the ProductValidator
effectively turning it into another singleton. The first time ProductValidator.validate
is called with product1
it adds an error to this.errors
, when it is called a second time with product2
it does not find find errors, but this.errors
still contains product1
state.
Granted, this can be easily solved by designing ProductValidator.validate
to just return the list of errors and not maintain any internal state, but hopefully you get the point of how mixing lifetimes can cause problems.
Best Practices to Avoid Mixing Lifetimes
-
Always Match Lifetimes: Ensure that services with longer lifetimes do not depend on services with shorter lifetimes. If a Singleton needs to use a service, consider whether the service should also be registered as a Singleton. If that’s not possible, redesign the service or its usage to avoid the conflict.
-
Use Factories for Transient Services: If a Singleton needs to use a Transient service, consider injecting a factory function that the Singleton can use to create instances of the Transient service as needed.
-
Leverage Scoped Services Correctly: Be mindful of when and how you use Scoped services, especially in non-web applications. In web applications, these services naturally align with the request lifecycle, but in other contexts, you may need to manually manage the scope.
-
Audit Your DI Registrations: Regularly review your service registrations, especially as your application grows. Look for potential lifetime mismatches and refactor accordingly. Tools like Scrutor can help automate some of this analysis.
My personal preference
Make everything a singleton and use a factory when you can't get away from a Transient service.
Let me caveat what I just said. This is my preference for web applications where performance is important, but it is good advice that can be used for any app.
Why singletons? Let's focus on web apps. As the application grows in features, so will the number of registered services and the size of the object graph created by the DI container. Maintaining a single object graph is much more performant than creating a new one on each request. This may not show up as a problem in the beginning, but as your app scales and the number of req/s grows the creation of entire object graphs will show up during memory profiling. It is not so much the time it takes to create the object graph, it is the work the GC is left with: having to clean up all the garbage generated after each request.