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

So why would you ever want to use property-based injection? While constructor-based injection is generally preferred for the reasons discussed earlier, there are specific use cases where property-based injection can be advantageous or necessary. One of the most common scenarios is when you have a base class that needs certain dependencies, but you don't want to force all derived classes to pass those dependencies through their constructors.

Avoid Constructor Bloat in Derived Classes

If you have multiple derived classes, each inheriting from a base class that requires dependencies, using constructor-based injection would mean that every derived class would need to pass those dependencies up to the base class through the constructor, even if they don't directly use those dependencies. This can clutter the constructor and make the derived classes harder to manage.
Property-based injection allows you to inject dependencies into the base class directly, without affecting the constructor of the derived classes.

Simplifying Inheritance

In complex inheritance hierarchies, where a base class manages shared dependencies, property-based injection can reduce the complexity of managing dependencies across multiple derived classes.
It allows you to inject the dependencies into the base class once, without needing to propagate them through every derived class.

Decoupling Dependencies

Property-based injection can be useful when you want to decouple dependencies from the constructor signature, which can help if the derived classes don’t need to know about the injected dependencies or if the dependency is only relevant to the base class.
It helps keep derived class constructors clean and focused on their specific dependencies.

Example:

LoggerService
import { Injectable } from '@nestjs/common';

@Injectable()
export class LoggerService {
public log(message: string) {
console.log('Log:', message);
}
}
BaseController injecting the LoggerService using property-based injection
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);
}
}

In the above example, the base class BaseController uses property-based injection to inject the LoggerService directly into the class without requiring it in the constructor. This allows any derived class to access logging functionality without needing to manage the LoggerService itself.

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 LandmarkController doesn't need to deal with the LoggerService. It inherits the logging functionality from BaseController and can use it directly through the log() method.
  • If you were using constructor-based injection, you would have to pass the LoggerService into 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)

Version of BaseController using constructor-based injection
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);
}
}
Below is a version of LandmarkController depending on the version of BaseController using constructor-based injection
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);
}
}
With constructor-based injection, every derived class must pass the 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

Popular posts from this blog

Node.js Microservice with NestJS: Part 3 - NestJS Dependency Injection

REST APIs with Controllers - Node.js Microservice with NestJS: Part 4