Nestjs: Programming Paradigm, IoC, DI and more

Nestjs: Programming Paradigm, IoC, DI and more

I didn't plan to publish this; it was initially for my own reference. This blog complements the official Nestjs documentation.

Use Cases

  1. Standalone Applications

    These applications don't handle network traffic like HTTP or WebSocket connections but still use the NestJS IoC container, benefiting from NestJS's dependency injection features.

    Ex. CLI Tools

  2. HTTP Servers

    To handle HTTP requests and responses.

  3. Microservices

    Microservices decompose the application into smaller, independent services that communicate with each other.

IOC and DI

IOC (Inversion of Control) and DI (Dependency Injection) are two core concepts in NestJs. Excerpt from IOC vs DI provides great insights into these two:

IOC (Inversion of Control) and DI (Dependency Injection) are related concepts in software development, but they are not the same thing.

IOC is a programming principle that is used to invert the flow of control in an application. Instead of the application being responsible for controlling the flow of execution, IOC allows the framework or container to take control. The framework provides the necessary infrastructure for the application to run, and the application uses this infrastructure to perform its functions.

DI, on the other hand, is a design pattern that is used to manage the dependencies between different components in an application. In DI, the dependencies are "injected" into a component by a container or framework, rather than the component itself creating or managing its own dependencies. This allows for greater flexibility and modularity in an application, as components can be easily swapped out or replaced without affecting the rest of the application.

In other words, IOC is a principle that allows for greater flexibility in how an application is executed, while DI is a design pattern that allows for greater flexibility in how an application is structured and managed.

In practice, IOC and DI are often used together, as DI is a common way to implement IOC. By using DI to manage dependencies, the framework or container can take control of the application's execution and provide the necessary infrastructure for the application to run.

Benefits of IoC

  • Loose Coupling: IoC promotes loose coupling by removing the direct dependencies between components, making the system more flexible and easier to maintain.

  • Testability: Since dependencies are injected from outside, it becomes easier to replace real implementations with mocks or stubs during testing.

  • Reusability: IoC allows components to be reused in different contexts because they depend on abstractions rather than concrete implementations.

IoC Techniques

NestJS uses Dependency Injection, which is the most common form of IoC.

// Without IoC (Traditional Control Flow)
class Engine {
  start() {
    console.log('Engine started');
  }
}

class Car {
  constructor() {
    this.engine = new Engine(); // Car controls the creation of Engine
  }

  start() {
    this.engine.start();
  }
}

const myCar = new Car();
myCar.start();

// With IoC (Using Dependency Injection)
class Engine {
  start() {
    console.log('Engine started');
  }
}

class Car {
  constructor(engine) { // Engine is injected via constructor
    this.engine = engine;
  }

  start() {
    this.engine.start();
  }
}

const myEngine = new Engine();
const myCar = new Car(myEngine); // Control of Engine creation is outside Car
myCar.start();

Programming Paradigms

  1. Object-Oriented Programming (OOP)
  • Encapsulation

    As users is marked as private, no external code can directly access or modify it.

    By encapsulating the users array and providing controlled methods to access and modify it, the UsersService class ensures that the internal state (i.e., the list of users) can only be altered in specific, predictable ways. This prevents the array from being modified arbitrarily, which could lead to bugs or inconsistent data.

      @Injectable()
      export class UsersService {
        // Private property (Encapsulation)
        private readonly users: User[] = [];
    
        // Public method to retrieve a user by ID (Controlled Access)
        getUser(id: number): User {
          return this.users.find(user => user.id === id);
        }
    
        // Public method to create a new user (Controlled Modification)
        createUser(createUserDto: CreateUserDto): User {
          const newUser = { id: Date.now(), ...createUserDto };
          this.users.push(newUser);
          return newUser;
        }
      }
    
  • Inheritance

      @Injectable()
      export class BaseService {
        createUser(name: string) { ... }
        getUser(id: number) { ... }
      }
    

    BaseService: Contains common functionality for managing users (e.g., createUser and getUser methods).

      @Injectable()
      export class UserService extends BaseService {
        // User-specific methods
      }
    
      @Injectable()
      export class AdminService extends BaseService {
        // Admin-specific method
        deleteUser(id: number) { ... }
      }
    

    UserService: Inherits from BaseService and can use its methods.

    AdminService: Also inherits from BaseService, so it has access to the common methods. Additionally, it has an admin-specific method, deleteUser.

    Using the Services in a Controller:

      @Controller('users')
      export class UsersController {
        constructor(
          private readonly userService: UserService,
          private readonly adminService: AdminService,
        ) {}
    
        @Get(':id')
        getUser(@Param('id') id: number) {
          return this.userService.getUser(id);
        }
    
        @Post('admin/delete/:id')
        deleteUser(@Param('id') id: number) {
          return this.adminService.deleteUser(id);
        }
    
  • Polymorphism

    Polymorphism enables objects of different classes to be treated as if they were objects of a common superclass. Polymorphism establishes a common interface (or supertype) that defines a set of methods and their signatures. Objects of different classes that implement this interface can then be used interchangeably, as the compiler or runtime environment will invoke the appropriate method based on the actual object's type.

    💡
    We can also enhance above setup by implementing UserService and AdminService as instances of BaseService if we ever needed to handle them polymorphically.
      interface PaymentService {
        processPayment(amount: number): void;
      }
    
      @Injectable()
      export class StripePaymentService implements PaymentService {
        processPayment(amount: number): void { ... }
      }
    
      @Injectable()
      export class PayPalPaymentService implements PaymentService {
        processPayment(amount: number): void { ... }
      }
    

    The PaymentService interface defines a contract that any implementing class must follow. StripePaymentService and PayPalPaymentService are two classes that implement the PaymentService interface. Both classes provide their own version of the processPayment method.

      @Injectable()
      export class CheckoutService {
        constructor(private readonly paymentService: PaymentService) {}
    
        checkout(amount: number): void {
          this.paymentService.processPayment(amount);
        }
      }
    

    The CheckoutService class is designed to handle the checkout process. It does not know or care about the specifics of how the payment is processed. Instead, it relies on the PaymentService interface. Because the CheckoutService depends on the PaymentService interface rather than a concrete class, it can work with any class that implements this interface.

    Based on the user's selection, the application determines which payment service to use. This could be done via an internal configuration or a switch-case logic that maps the selected payment method to a corresponding payment service.

      let paymentService: PaymentService;
    
      switch (selectedPaymentMethod) {
        case 'PayPal':
          paymentService = new PayPalPaymentService();
          break;
        case 'Stripe':
          paymentService = new StripePaymentService();
          break;
        // Add more cases for other payment services
        default:
          throw new Error('Unsupported payment method');
      }
    

    The CheckoutService is instantiated with the selected paymentService and calls the processPayment method on the injected paymentService, which processes the payment according to the specific logic of the chosen service.

      const checkoutService = new CheckoutService(paymentService);
      checkoutService.checkout(amount);
    
  1. Dependency Injection

    NestJS uses Dependency Injection for managing dependencies and promoting loose coupling.

  2. Modular Programming

    In NestJS each module encapsulates a set of related components. Modules can be imported into other modules, allowing for scalable and maintainable code organization.


Let's connect: