Introduction
This is the last article of the series about SOLID. It is a set of principles that allows us to write a code which is easier to scale and to change the behavior of our application, without modifying a significant amount of code.
The set of rules consists of:
- Single Responsibility Principle,
- Open/Closed Principle,
- Liskov Substitution Principle,
- Interface Segregation Principle,
- Dependency Inversion Principle.
Now we are going to deal with the Dependency Inversion Principle.
Dependency Inversion Principle
Just like you can see in the picture when using electrical devices, we would rather not solder them directly into the electrical system. Instead, we simply plug the device into the socket ?
So you can think of this rule as creating “sockets” in our code to which we can interchangeably connect other devices (services, functions, etc.).
Formal definition
It sounds as follows:
- high-level modules should not depend on low-level modules
- both should depend on abstractions
- abstractions should not contain details (because those details should already be in the concrete implementation)
What benefits do we have by sticking to this rule?
- easily reusable, high-level modules (so-called application building blocks)
- changes to low-level modules should not affect high-level ones. So we have the possibility to change the behavior without modifying a big part of the application
So to summarize:
- a high-level module must depend on an abstraction (define it – create an interface)
- a low-level module must also depend on the same abstraction (implement it – provide an interface implementation)
Examples
The simplest example is Pipe in Angular. If it wasn’t for this interface, we wouldn’t be able to add custom Pipes to our applications (because then we’d have to add an IF handler for our particular Pipe in the Angular code).
High-level module: Angular – depends on abstraction (defines interface)
Low-level module: our application – implements the abstraction (implements the interface)
Another example.
Let’s suppose we have an ordering application for an online store. Therefore, we need to calculate the tax on the order.
The high-level module is a component with an injected service.
The low-level module is the service.
As long as our application runs within a single state, everything is simple. However, what if we would like to enter foreign markets? How do we calculate tax for different states?
A naive solution:
A service that will return the appropriate value based on the country code submitted:
@Injectable()
export class FeeCalculator {
calculate(code: CountryCode): number {
switch (code) {
case CountryCode.PL:
return 23;
case CountryCode.DE:
return 21;
}
}
}
Problem: what if we want to serve another country in our application? We need to add “IF”.
Let’s try to think more abstract. After all, we are looking for a service that will calculate the tax for a given country. So, let’s create an interface that we will implement depending on the need:
export abstract class FeeCalculator {
abstract calculate(): number;
}
PS This is the strategy design pattern ? .
Having separated the interface, we can move on to the implementation.
@Injectable()
export class PolandFeeCalculator implements FeeCalculator {
calculate(): number {
return 23;
}
}
@Injectable()
export class GermanFeeCalculator implements FeeCalculator {
calculate(): number {
return 21;
}
}
Now in the component, we will use the abstraction (interface) and not the concrete implementation.
@Component()
export class OrderComponent implements OnInit {
fee: number;
constructor(private feeCalculator: FeeCalculator) {}
ngOnInit(): void {
this.fee = this.feeCalculator.calculate();
}
}
Let’s look at how we can now provide an appropriate implementation at the module level.
@NgModule()
export class OrderModule {
static forPoland(): ModuleWithProviders<OrderModule> {
return {
ngModule: OrderModule,
providers: [
PolandFeeCalculator,
{
provide: FeeCalculator,
useExisting: PolandFeeCalculator,
},
],
};
}
}
Interesting fact:
What if we don’t know what implementation we want to use at the module level? I.e. we want to provide the implementation “on the fly”, in “Live” mode ?
Let’s suppose we get a country code as a parameter in a URL route.
So let’s define a factory that will provide us with appropriate implementations based on the country code we send:
@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')
}
}
}
We inject this factory 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();
}
}
Let’s move on to the next example:
Let’s suppose that we have a service that does CRUD operations on an entity (in the form of 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);
}
}
What is wrong? At first glance, nothing.
The problem arises if we wanted to experimentally introduce GraphQL support in one of the environments. Then we would have to add an IF that checks the environment in each 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
}
Problem – we modify the existing code, initiating an environment check with IF. If we wanted to use e.g. WebSockets on yet another environment, we would add another IF, and another dependency to the service.
How do we solve this?
Let’s separate the interface:
export abstract class FolderResource {
abstract create(data): Observable<void>;
abstract delete(data): Observable<void>;
abstract update(data): Observable<void>;
}
Let’s change the usage from a concrete implementation to an abstraction (interface).
Let’s provide a concrete implementation depending on the environment at the module level:
@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 way we preserve the Dependency Inversion Principle. By the way, I also recommend an article that shows the behavior of this rule when combining Angular with NestJS – https://staging.angular.love/en/2020/12/02/how-to-follow-the-dependency-inversion-principle-in-nestjs-and-angular/