Constructor-Based vs Property-Based Dependency Injection in NestJS - Which is Better?
Constructor-Based vs Property-Based Dependency Injection in NestJS - Which is Better?
Using constructor-based injection is generally considered better than property-based injection in NestJS (and in many other frameworks) for several key reasons. Lets look at a few reasons why and some scenarios where property-based injection may be more appropriate.
Immutability and Readability
- Constructor-based injection ensures that dependencies are provided at the time of object creation, making them immutable. This means that the dependencies cannot be reassigned after the object is instantiated.
- With property-based injection, dependencies are injected later, making them mutable. This can lead to side effects if the dependencies are modified during the object's lifetime.
- Constructor-based injection makes it clear what dependencies the class needs at the time of instantiation, improving code readability.
Example:
private readonly landmarkService: LandmarkService;
constructor(landmarkService: LandmarkService) {
this.landmarkService = landmarkService;
}
In the above example, it's explicit that the LandmarkService is a required dependency. This makes it easier to understand the class' requirements and by making it readonly, it ensures that the service can't be accidentally reassigned.
Dependency Initialization Guarantee
- With constructor-based injection, all dependencies are guaranteed to be initialized when the object is created, ensuring the class is always in a valid state. This reduces the likelihood of encountering null or undefined errors when accessing injected dependencies.
- With property-based injection, there’s a risk that the dependency may not be initialized when the class attempts to use it, leading to runtime errors.
Example of property-based risk:
export class LandmarkController {
@Inject()
private landmarkService: LandmarkService;
public async getLandmarks(criteria: LocalLandmarkCriteria) {
// Risk: landmarkService might not be initialized yet
return this.landmarkService.getLocalLandmarks(criteria);
}
}
If the property landmarkService is accessed before it is injected, it could lead to errors like "TypeError: Cannot read property getLandmarks of undefined".
Linting Rules and Uninitialized Properties
- TypeScript and linting tools like TSLint and ESLint often enforce strict rules to prevent the use of uninitialized properties. These tools will flag properties that are declared but not initialized in the constructor, unless explicit null or undefined checks are applied.
- With constructor-based injection, you avoid this issue entirely since all properties (dependencies) are initialized at object construction, ensuring that the class is fully defined when it is instantiated.
- In contrast, property-based injection may trigger linting errors if the properties are not initialized at declaration. Linting tools might require you to either initialize properties or use explicit null-checks to avoid potential bugs.
Example:
When linting rules such as strictPropertyInitialization are enabled, the linter will flag this property because TypeScript expects properties to be initialized either at declaration or in the constructor. To fix this, you might have to initialize landmarkService to null or use `!`, which defeats the purpose of strong typing and can lead to errors:
export class LandmarkController {
@Inject()
private landmarkService!: LandmarkService; // Using `!` bypasses null checks, which can be dangerous
}
Constructor-based injection avoids this problem entirely:
export class LandmarkController {
private readonly landmarkService: LandmarkService; // No linting issues
constructor(landmarkService: LandmarkService) {
this.landmarkService = landmarkService;
}
}
This ensures that all dependencies are present and initialized when the class is created, making the code cleaner and preventing issues flagged by linters.
Testability and Explicit Dependencies
- Constructor-based injection makes it easier to mock dependencies during testing. Since all dependencies are declared in the constructor, it's clear what needs to be mocked, simplifying the setup process.
- Property-based injection, on the other hand, hides dependencies within the class, making it harder to understand what needs to be mocked or passed in during testing.
Example of constructor-based testing:
describe('LandmarkController', () => {
test('getLandmarks returns appropriately', () => {
const mockLandmarkService = {
getLandmarks: jest.fn(() => ['Mock Landmark']),
};
const controller = new LandmarkController(mockLandmarkService);
expect(controller.getLandmarks()).toEqual(['Mock Landmark']);
});
});
In this case, it's straightforward to pass a mock LandmarkService into the controller because the constructor clearly defines it as a dependency.
Single Responsibility Principle (SRP)
- Constructor-based injection adheres more closely to the Single Responsibility Principle (SRP) from the SOLID principles. By declaring dependencies in the constructor, you make the responsibilities of the class more explicit and easier to reason about.
- Property-based injection can make it harder to understand what dependencies the class has, leading to classes that are more difficult to maintain and refactor.
Framework Compatibility and Best Practices
- Most frameworks, including NestJS, recommend constructor-based injection as a best practice. It aligns better with TypeScript's strong typing system and integrates smoothly with NestJS’s IoC container.
- Property-based injection is less commonly used and often discouraged because it can introduce issues related to uninitialized dependencies and harder-to-debug runtime errors.
Cleaner and Simpler Lifecycle Management
- Constructor-based injection ensures that all dependencies are initialized upfront, reducing the complexity of the class lifecycle. The dependencies are fully initialized when the class is created, so there is no need for additional checks or lifecycle management tasks.
- With property-based injection, you may need to implement lifecycle hooks or add logic to ensure that the properties are properly initialized before use, which complicates the code and introduces potential failure points.
Why Use Property Based Injection
Avoid Constructor Bloat in Derived Classes
Simplifying Inheritance
Decoupling Dependencies
Example:
import { Injectable } from '@nestjs/common';
@Injectable()
export class LoggerService {
public log(message: string) {
console.log('Log:', message);
}
}
import { Inject } from '@nestjs/common';
import { LoggerService } from './logger.service';
export abstract class BaseController {
@Inject()
protected readonly loggerService: LoggerService; // Property-based injection
public log(message: string) {
this.loggerService.log(message);
}
}
Derived Classes: Clean Constructors
The derived LandmarkController class example below doesn't need to worry about the LoggerService and can focus on its own dependencies:
import { LocalLandmarkCriteria } from '../common';
import { BaseController } from './base.controller';
import { LandmarkService } from './landmark.service';
export class LandmarkController extends BaseController {
private readonly landmarkService: LandmarkService;
constructor(landmarkService: LandmarkService) {
super();
this.landmarkService = landmarkService;
}
getLandmarks(criteria: LocalLandmarkCriteria) {
this.log('Fetching landmarks...'); // Using the logger from the base class
return this.landmarkService.getLocalLandmarks(criteria);
}
}
In the above example:
- The
LandmarkControllerdoesn't need to deal with theLoggerService. It inherits the logging functionality fromBaseControllerand can use it directly through thelog()method. - If you were using constructor-based injection, you would have to pass the
LoggerServiceinto the constructor of every derived controller class, even if the derived classes don’t directly need it as in the below example
Constructor-Based Injection (Less Ideal in This Case)
import { LoggerService } from './logger.service';
export abstract class BaseController {
protected readonly loggerService: LoggerService; // Property-based injection
constructor(loggerService: LoggerService) {
this.loggerService = loggerService;
}
public log(message: string) {
this.loggerService.log(message);
}
}
import { LocalLandmarkCriteria } from '../common';
import { BaseController } from './base.controller';
import { LandmarkService } from './landmark.service';
import { LoggerService } from './logger.service';
export class LandmarkController extends BaseController {
private readonly landmarkService: LandmarkService;
constructor(landmarkService: LandmarkService, loggerService: LoggerService) {
super(loggerService);
this.landmarkService = landmarkService;
}
getLandmarks(criteria: LocalLandmarkCriteria) {
this.log('Fetching landmarks...'); // Using the logger from the base class
return this.landmarkService.getLocalLandmarks(criteria);
}
}
LoggerService to the base class constructor, even though the derived class doesn't directly use the logger service. This adds unnecessary complexity to the derived classes and exposes classes to them that they would otherwise not need to know about.Final Thoughts
In conclusion, constructor-based injection is generally the preferred approach in NestJS for several important reasons:
- It ensures immutability by setting dependencies at the time of object creation.
- It guarantees that dependencies are initialized when the class is instantiated, reducing the risk of runtime errors caused by accessing uninitialized properties.
- Linting tools such as ESLint or TSLint are less likely to flag uninitialized properties in constructor-based injection, providing cleaner and more reliable code.
- Constructor injection improves testability by making dependencies explicit and allowing easy mock replacements.
- It adheres to the Single Responsibility Principle (SRP) by ensuring that the class's dependencies are clearly defined and organized, making the code easier to maintain and refactor.
However, property-based injection can be useful in specific scenarios, particularly when working with inheritance hierarchies:
- It helps to avoid constructor bloat in derived classes, where shared dependencies (managed by a base class) can be injected directly into the base class without burdening derived classes with unnecessary constructor arguments.
- Decoupling derived classes from base class dependencies simplifies the constructor signatures of derived classes, keeping them clean and focused on their own specific requirements.
- In cases where a dependency is primarily relevant to the base class (e.g., logging, shared utility services), property-based injection ensures that the base class has what it needs without propagating the dependency through every derived class.
While property-based injection can reduce complexity in inheritance-heavy designs, it comes with trade-offs:
- You need to ensure that injected properties are initialized before they are used to avoid errors with uninitialized properties.
- This method can sometimes lead to less explicit code, making dependencies harder to track and maintain.
In general, for the majority of use cases in NestJS, constructor-based injection remains the best practice due to its clarity, testability, and type safety. However, property-based injection can be a valuable tool when working with base and derived class relationships, allowing for more flexible dependency management and cleaner derived class constructors. It's important to choose the right injection strategy based on the specific needs and complexity of your application.
Comments
Post a Comment