Introduction

This closing piece in the SOLID series wraps up the set of principles that help us build software that scales gracefully and allows behavioral changes without touching large swaths of the codebase.

The principles include:

Our focus here is the final rule — Dependency Inversion.

Dependency Inversion Principle

Angular & Dependency Inversion Principle — figure 1

Think of household appliances: we don't hardwire them into the electrical grid permanently. Instead, we connect them to a wall socket whenever needed.

This principle essentially encourages us to build similar "sockets" into our code — points where interchangeable services, functions, or other components can be connected and swapped out at will.

Formal definition

The formal statement goes like this:

  • high-level modules must not rely on low-level modules
  • both should depend on abstractions
  • abstractions should remain free of details (details belong in the concrete implementation)

What do we gain from following this rule?

  • high-level modules become reusable building blocks
  • modifications in low-level modules leave high-level ones untouched, so we can shift behavior without rewriting a large portion of the application

In short:

  • a high-level module depends on an abstraction (it defines an interface)
  • a low-level module depends on the same abstraction (it implements that interface)

Examples

Angular's Pipe is a classic case. Without that interface, adding custom Pipes would require adding an if statement inside the framework's own code for every new pipe we create.

Angular & Dependency Inversion Principle — figure 2

High-level module: Angular — it defines the interface (abstraction)

Low-level module: our app — it provides the interface implementation

Here's another scenario.

Imagine an ordering system for an online shop. We need to compute tax for each order.

Angular & Dependency Inversion Principle — figure 3

The high-level module is the component injecting a service.

The low-level module is that service itself.

When the app targets one market, everything is straightforward. But expanding to other countries? How would tax calculation vary by region?

 A naive approach:

A service that picks the right value based on a country code:

@Injectable()
export class FeeCalculator {
 calculate(code: CountryCode): number {
   switch (code) {
     case CountryCode.PL:
       return 23;
     case CountryCode.DE:
       return 21;
   }
 }
}

The downside: introducing another country forces us to add another if branch.

Let's step back and think abstractly. We really just need a service that computes tax for a given country. So, we define an interface, and then create implementations as needed:

export abstract class FeeCalculator {
 abstract calculate(): number;
}

Note: this is the strategy design pattern in action.

With the interface in place, we can write the concrete implementations.

@Injectable()
export class PolandFeeCalculator implements FeeCalculator {
 calculate(): number {
   return 23;
 }
}
@Injectable()
export class GermanFeeCalculator implements FeeCalculator {
 calculate(): number {
   return 21;
 }
}

In the component, we now reference the interface, not any particular implementation.

@Component()
export class OrderComponent implements OnInit {
 fee: number;

 constructor(private feeCalculator: FeeCalculator) {}

 ngOnInit(): void {
   this.fee = this.feeCalculator.calculate();
 }
}

Now, let's examine how we register the appropriate implementation at the module level.

@NgModule()
export class OrderModule {
 static forPoland(): ModuleWithProviders<OrderModule> {
   return {
     ngModule: OrderModule,
     providers: [
       PolandFeeCalculator,
       {
         provide: FeeCalculator,
         useExisting: PolandFeeCalculator,
       },
     ],
   };
 }
}

An interesting twist:

What if we don't know at module level which implementation to use? In other words, we need to pick it dynamically, at runtime.

Say the country code arrives as a route parameter in the URL.

We can define a factory that produces the right implementation based on the country code:

@Injectable()
export class FeeCalculatorFactory {
 fromCode(code: CountryCode): FeeCalculator {
   switch (code) {
     case CountryCode.PL:
       return new PolandFeeCalculator();
     case CountryCode.DE:
       return new GermanFeeCalculator();
     default:
       throw new Error('Unknown country')
   }
 }
}

This factory gets injected into the component:

@Component()
export class OrderComponent implements OnInit {
 fee: number;

 constructor(
   private feeCalculatorFactory: FeeCalculatorFactory,
   private route: ActivatedRouteSnapshot
 ) {}

 ngOnInit(): void {
   const country = this.route.queryParamMap.get('country');
   const calculator = this.feeCalculatorFactory.fromCode(country);
   this.fee = calculator.calculate();
 }
}

On to the next example:

Consider a service performing CRUD operations on an entity via HTTP requests:

@Injectable()
export class FolderDataService {
 constructor(private http: HttpClient) {}

 create(data): Observable<void> {
   return this.http.post<void>('api-url.com/folders', data);
 }

 delete(data): Observable<void> {
   return this.http.delete<void>(`api-url.com/folders/${data.id}`);
 }

 update(data): Observable<void> {
   return this.http.put<void>(`api-url.com/folders/${data.id}`, data);
 }
}

At first glance, this seems fine.

The trouble starts if we want to experiment with GraphQL support in a specific environment. We'd then need to add an if check for the environment inside every method:

@Injectable()
export class FolderDataService {
 constructor(private graphQl: GraphQLClient, private http: HttpClient) {}

 create(data): Observable<void> {
   if (env === 'experimental') {
     return this.graphQl.execute(...);
   }
  
 // other methods
}

The problem is that we're modifying working code and introducing environment checks with if statements. If we later want WebSockets in yet another environment, we'd nest another if and pull more dependencies into the service.

How can we resolve this?

First, extract an interface:

export abstract class FolderResource {
 abstract create(data): Observable<void>;

 abstract delete(data): Observable<void>;

 abstract update(data): Observable<void>;
}

Switch the consumer from the specific class to the interface.

Then, at the module level, provide the concrete implementation based on the current environment:

@NgModule()
export class FolderModule {
 static forExperimental(): ModuleWithProviders<FolderModule> {
   return {
     ngModule: FolderModule,
     providers: [
       GraphQlFolderResource,
       {
         provide: FolderResource,
         useExisting: GraphQlFolderResource
       }
     ]
   }
 }

 static forStaging(): ModuleWithProviders<FolderModule> {
   return {
     ngModule: FolderModule,
     providers: [
       HttpFolderResource,
       {
         provide: FolderResource,
         useExisting: HttpFolderResource
       }
     ]
   }
 }
}

This adheres to the Dependency Inversion Principle. I'd also recommend an article that explores how this principle plays out when Angular meets NestJS – https://wp.angular.love/en/2020/12/02/how-to-follow-the-dependency-inversion-principle-in-nestjs-and-angular/