DEV Community

Cover image for Elevating Code Flexibility: Dependency Inversion with DI Containers
Afraz Khan
Afraz Khan

Posted on • Edited on

Elevating Code Flexibility: Dependency Inversion with DI Containers

Having tight-coupling between classes and objects is consistently detrimental, as it diminishes code flexibility and reusability. Each modification to a single component necessitates the vigorous task of dealing with its dependencies, causing developers to face recurring challenges.
In the realm of software development, the following design patterns serve as fundamental pillars extensively employed to eliminate tight coupling, especially when adhering to the SOLID principles.

  • Inversion-of-Control (IoC)
  • Dependency-Injection (DI)
  • Dependency-Inversion-Principle (DIP)

These patterns offer developers the means to decouple dependencies and achieve code that is flexible, maintainable, and extensible.

Furthermore, the modern landscape of development practices has witnessed the emergence of highly acclaimed "DI Containers" frameworks. These frameworks have revolutionized the implementation of IoC and DI, empowering developers to construct software architectures that are robust, modular, and scalable.

Lets review a practical example to employ the powerful Dependency Inversion Principle that results in modular and clean design. Examples are written for a TypeScript-based codebase utilizing InversifyJS as the DI Container.

Preliminary Setup

Lets expect that your application has a DI container set up, with each class appropriately attached to it using the necessary procedures. In InversifyJS, creating an application container is as straightforward as shown below:

AppContainer.ts

import {Container} from 'inversify';
import {ServiceOne, ServiceTwo} from '../services';

export class AppContainer {
  private static container: Container;

  public static build(){
    const container = new Container();

    container.bind<ServiceOne>('ServiceOne').to(ServiceOne);
    container.bind<ServiceTwo>('ServiceTwo').to(ServiceTwo);
  }
}
Enter fullscreen mode Exit fullscreen mode

Practical Use Case

Lets take a fictional Authentication module in a codebase that solely relies on Google as the Identity Provider for managing authentication. However, a new requirement has emerged to incorporate Amazon as an additional Identity Provider.

๐Ÿ‘‡ Current Code Structure

Imagine a service named Authenticator that primarily relies on Google as the identity provider, exemplified in the code snippet below.

Authenticator.ts

export class Authenticator {

  public initiateUserAuth(){
    // Google specific operations    
    .
  }

  public logoutUser(){
    // Google specific operations    
    .
  }
  .
  .
}
Enter fullscreen mode Exit fullscreen mode

โญ• Tight Coupling found

In this scenario, the code exhibits tight coupling as the Authenticator service directly relies on the Google as the Identity Provider. To accommodate new Identity Providers, the code requires the introduction of if/else statements and as the number of Identity Providers grows, maintaining this code becomes increasingly complex and challenging. Now, it becomes evident that a significant restructuring is necessary to accommodate the integration of a new Identity Provider.

Looks like an exhastive task? ๐Ÿ™‚

๐Ÿฏ Desired Objective

The seamless integration of new Identity Providers should be accomplished without requiring any significant or even zero modifications to the Authenticator service.

๐Ÿ” Analysis

By leveraging the Dependency Inversion Principle, we can decouple the Authenticator service from the specific Google implementation. This involves introducing an abstract class or interface as a protocol for all Identity Providers to adhere to. Such decoupling ensures that changes or additions to low-level modules, like Google or Amazon Identity Provider, won't directly affect the high-level Authenticator module, promoting modularity and maintainability in the codebase.

Resolution

We require a substantial yet straightforward restructuring, outlined as follows:

  1. Introduce a new module called IdentityProvider. Within this module,

    • Create an interface named IIdentityProvider that encompasses essential methods and properties universally applicable to all Identity Providers in your system.
    • Create an enum EIdentityProvider for the Identity Provider types.

    IIdentityProvider.ts

    export interface IIdentityProvider {
      fetchTokens(): string[],
      revokeTokens(): void
    }
    
    export enum EIdentityProvider {
      GOOGLE = 'GOOGLE',
      AMAZON = 'AMAZON'
    }
    
  2. Create a new GoogleIdentityProvider class which implements the IIdentityProvider interface.

    GoogleIdentityProvider.ts

    export class GoogleIdentityProvider implements IIdentityProvider{
        fetchTokens(): string[] {
           // google specific operations
        }
        revokeTokens(): void {
          // google specific operations
        }
    }
    
  3. Similar to GoogleIdentityProvider, Create a new AmazonIdentityProvider class.

    AmazonIdentityProvider.ts

    export class AmazonIdentityProvider implements IIdentityProvider{
        fetchTokens(): string[]{
          // Amazon specific operations
        }
        revokeTokens(): void{
          // Amazon specific operations
        }
    }
    
  4. Refactor the Authenticator class.

    • Introduce a new property called identityProvider, which stores the relevant Identity Provider instance based on the use case. Utilize this property to carry out the authentication process.

    Authenticator.ts

    import {inject} from 'inversify';
    
    export class Authenticator {
      protected identityProvider: IIdentityProvider;
    
      constructor(identityProvider: IIdentityProvider) {
        this.identityProvider = identityProvider;
      }
    
      /* Authentication-related methods that are independent
         of any specific Identity Provider. */
      public initiaUserAuth(){
        .
        .
        return this.identityProvider.fetchTokens();
      }
    
      public logoutUser(){
        .
        .
        return this.identityProvider.revokeTokens();
      }
      .
      .
      .
    }
    
  5. Configure multiple instances of the Authenticator class in the DI Container. Each instance is initialized using a specific IdentityProvider type. To accomplish this, we will leverage the service identification methods offered by your DI Container framework.

    See InversifyJS example below.

    AppContainer.ts

    import {Container} from 'inversify';
    import {Authenticator, GoogleIdentityProvider, AmazonIdentityProvider, IdentityProvider} from '../services';
    
    export class AppContainer {
      private static container: Container;
    
      public static build(){
        const container = new Container();
    
        /* Initialize an instance of each Identity Provider 
           class. */
        container.bind<GoogleIdentityProvider('GoogleIdentityProvider')
          .to(GoogleIdentityProvider);
        container.bind<AmazonIdentityProvider>('AmazonIdentityProvider')
          .to(AmazonIdentityProvider);
    
        /* Initialize multiple Authenticator instances based on 
           each Identity Provider type. */
        container.bind<Authenticator>('Authenticator')
          .toDynamicValue(context => {
            return new Authenticator(context.container.get('GoogleIdentityProvider');
          })
          .whenTargetTagged('IdentityProvider', EIdentityProvider.GOOGLE);
        container.bind<Authenticator>('Authenticator')
          .toDynamicValue(context => {
            return new Authenticator(context.container.get('AmazonIdentityProvider);
          })
          .whenTargetTagged('IdentityProvider', EIdentityProvider.AMAZON);
      }
    }
    
  6. Refactor the code, specifically in the sections where you want to load the suitable Authenticator instance based on the required Identity Provider type in the use case.

    Take a look at the provided InversifyJS examples in a hypothetical Authentication Controller scenario.

    AuthController.ts

    import {AppContainer} from '../AppContainer.ts';
    import {Authenticator} from '../Authenticator.ts';
    
    export class AuthController{
      protected authenticator: Authenticator;
    
      @Post('/auth/google')
      public async initiateGoogleAuth(){
    
        // Loading Authenticator instance with GOOGLE Identity Provider
        this.authenticator = AppContainer.getTagged(
            'Authenticator',
            'IdentityProvider',
            EIdentityProvider.GOOGLE
        );
        .
        .
        .
      }
    
      @Post('/auth/amazon')
      public async initiateAmazonAuth(){
    
        // Loading Authenticator instance with AMAZON Identity Provider
        this.authenticator = AppContainer.getTagged(
            'Authenticator',
            'IdentityProvider',
            EIdentityProvider.AMAZON
        );
        .
        .
        .
      }
    }
    

Default Implementation with Decorator Pattern

If ๐Ÿ“œdefault behavior is necessary for your Identity Providers, considering an abstract class instead of an interface is a viable option. Moreover, if that default behavior serves as a valid form of an identity provider, it may be appropriate for the class to be concrete.
Other specific Identity Providers can inherit from this class, allowing them to modify or override the functionality as required. This particular scenario is a great application of the Decorator Pattern.

  • Create a component Interface.
  • Create a default-identity-provider/component class which implements component interface.
  • Create decorator/specific-identity-provider classes that inherit from the component class.

A good Decorator Pattern example here.


I hope, this helped you:), happy learning๐Ÿš€

refs 1, 2

Top comments (0)