Elevating Code Flexibility: Dependency Inversion with DI Containers
Afraz Khan
Posted on July 3, 2023
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.
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);
}
}
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
.
}
.
.
}
โญ 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:
-
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' }
- Create an interface named
-
Create a new
GoogleIdentityProvider
class which implements theIIdentityProvider
interface.GoogleIdentityProvider.ts
export class GoogleIdentityProvider implements IIdentityProvider{ fetchTokens(): string[] { // google specific operations } revokeTokens(): void { // google specific operations } }
-
Similar to
GoogleIdentityProvider
, Create a newAmazonIdentityProvider
class.AmazonIdentityProvider.ts
export class AmazonIdentityProvider implements IIdentityProvider{ fetchTokens(): string[]{ // Amazon specific operations } revokeTokens(): void{ // Amazon specific operations } }
-
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(); } . . . }
- Introduce a new property called
-
Configure multiple instances of the
Authenticator
class in the DI Container. Each instance is initialized using a specificIdentityProvider
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); } }
-
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๐
Posted on July 3, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.