The D letter in SOLID is the Dependency Inversion principle. It helps to decouple modules from each other so that you can easily swap one part of the code for another.
One of the techniques that helps to follow this principle is Dependency Injection.
This post was inspired by Sasha Bespoyasov's article and is partially a translation of it.
What Are Dependencies?
For ease of reference, we will define a dependency as any module that is used by our module.
Let's look at a function that takes two numbers and returns a random number in a range:
const getRandomInRange = (min: number, max: number): number =>
Math.random() * (max - min) + min;
The function depends on two arguments: min
and max
.
But you can see that the function depends not only on the arguments, but also on the Math.random
function. If Math.random
is not defined, our getRandomInRange
function won't work either. That is, getRandomInRange
depends on the functionality of another module. Therefore Math.random
is also a dependency.
Let's explicitly pass the dependency through arguments:
const getRandomInRange = (
min: number,
max: number,
random: () => number,
): number => random() * (max - min) + min;
Now the function uses not only two numbers, but also a random
function that returns number. We will call the getRandomInRange
like this:
const result = getRandomInRange(1, 10, Math.random);
To avoid passing Math.random
all the time, we can make it the default value for the last argument.
const getRandomInRange = (
min: number,
max: number,
random: () => number = Math.random
): number => random() * (max - min) + min;
This is the primitive implementation of the Dependency Inversion. We pass to our module all the dependencies it needs to work.
Why Is It Needed?
Really, why put Math.random
in the argument and use it from there? What's wrong with just using it inside a function? There are two reasons for that.
Testability
When all dependencies are explicitly declared, the module is easier to test. We can see what needs to be prepared to run a test. We know which parts affect the operation of this module, so we can replace them with some simple implementation or mock implementation if needed.
Mock implementations make testing a lot easier, and sometimes you can't test anything at all without them. As in the case of our function getRandomInRange
, we cannot test the final result it returns, because it is.... random.
/*
* We can create a mock function
* that will always return 0.1 instead of a random number:
*/
const mockRandom = () => 0.1;
/* Next, we call our function by passing the mock object as its last argument: */
const result = getRandomInRange(1, 10, mockRandom);
/*
* Now, since the algorithm within the function is known and deterministic,
* the result will always be the same:
*/
console.log(result === 1); // -> true
Replacing an Dependency With Another Dependency
Swapping dependencies during tests is a special case. In general, we may want to swap one module for another for some other reason.
If the new module behaves the same as the old one, we can replace one with the other:
const otherRandom = (): number => {
/* Another implementation of getting a random number... */
};
const result = getRandomInRange(1, 10, otherRandom);
But can we guarantee that the new module will behave the same way as the old one? Yes we can, because we use the () => number
argument type. This is why we use TypeScript, not JavaScript. Types and interfaces are links between modules.
Dependencies on Abstractions
At first glance, this may seem like overcomplication. But in fact, with this approach:
- modules become less dependent on each other;
- we are forced to design the behavior before we start writing the code.
When we design behavior in advance, we use abstract conventions. Under these conventions, we design our own modules or adapters for third-party ones. This allows us to replace parts of the system without having to rewrite it completely.
This especially comes in handy when the modules are more complex than in the examples above.
Dependency Injection
Let's write yet another counter. Our counter can increase and decrease. And it also logs the state of.
class Counter {
public state: number = 0;
public increase(): void {
this.state += 1;
console.log(`State increased. Current state is ${this.state}.`);
}
public decrease(): void {
this.state -= 1;
console.log(`State decreased. Current state is ${this.state}.`);
}
}
Here we run into the same problem as before — we use not only the internal state of the class instance, but also another module — console
. We have to inject this dependence.
If in functions we passed dependencies through arguments, then in classes we will inject dependencies through the constructor.
Our Counter
class uses the log method of the console object. This means that the class needs to pass some object with the log
method as a dependency. It doesn't have to be console
— we keep in mind the testability and replaceability of modules.
interface Logger {
log(message: string): void;
}
class Counter {
constructor(
private logger: Logger,
) {}
public state: number = 0;
public increase(): void {
this.state += 1;
this.logger.log(`State increased. Current state is ${this.state}.`);
}
public decrease(): void {
this.state -= 1;
this.logger.log(`State decreased. Current state is ${this.state}.`);
}
}
We will then create instances of this class as follows:
const counter = new Counter(console);
And if we want to replace console
with something else, we just have to make sure that the new dependency implements the Logger
interface:
const alertLogger: Logger = {
log: (message: string): void => {
alert(message);
},
};
const counter = new Counter(alertLogger);
Automatic Injections and DI Containers
Now the class doesn't use implicit dependencies. This is good, but this injection is still awkward: you have to add references to objects by hand every time, try not to mix up the order if there are several of them, and so on…
This should be handled by a special thing — a DI Container. In general terms, a DI Container is a module that only provides dependencies to other modules.
The container knows which dependencies which module needs, creates and injects them when needed. We free objects from the obligation to keep track of their dependencies, the control goes elsewhere, as the letters S and D in SOLID imply.
The container will know which objects with which interfaces are required by each of the modules. It will also know which objects implement those interfaces. And when creating objects that depend on such interfaces, the container will create them and access them automatically.
Automatic Injections in Practice
We will use the Brandi DI Container, which does exactly what we described above.
Let's begin by declaring the Logger
interface and creating its ConsoleLogger
implementation:
/* Logger.ts */
export interface Logger {
log(message: string): void;
}
export class ConsoleLogger implements Logger {
public log(message: string): void {
console.log(message)
}
}
Now we need to get to know the tokens. Since we are compiling TypeScript into JavaScript, there will be no interfaces and types in our code. Brandi uses tokens to bind dependencies to their implementations in JavaScript runtime.
/* tokens.ts */
import { token } from 'brandi';
import { Logger } from './Logger';
import { Counter } from './Counter';
export const TOKENS = {
logger: token<Logger>('logger'),
counter: token<Counter>('counter'),
};
The code above mentions the Counter
. Let's see how dependencies are injected into it:
/* Counter.ts */
import { injected } from 'brandi';
import { TOKENS } from './tokens';
import { Logger } from './Logger';
export class Counter {
constructor(
private logger: Logger,
) {}
/* Other code... */
}
injected(Counter, TOKENS.logger);
We use the injected
function to specify by which token the dependency should be injected.
Since tokens are typed, it is not possible to inject a dependency with a different interface, this will throw an error at compile time.
Finally, let's configure the container:
/* container.ts */
import { Container } from 'brandi';
import { TOKENS } from './tokens';
import { ConsoleLogger } from './logger';
import { Counter } from './counter';
export const container = new Container();
container
.bind(TOKENS.logger)
.toInstance(ConsoleLogger)
.inTransientScope();
container
.bind(TOKENS.counter)
.toInstance(Counter)
.inTransientScope();
We bound tokens to their implementation.
Now when we get an instance from the container, its dependencies will be injected automatically:
/* index.ts */
import { TOKENS } from './tokens';
import { container } from './container';
const counter = container.get(TOKENS.counter);
counter.increase() // -> State increased. Current state is 1.
What's the inTransientScope()
?
Transient is the kind of instance lifecycle that the container will create.
-
inTransientScope()
— a new instance will be created each time it is gotten from the container; -
inSingletonScope()
— each getting will return the same instance.
Brandi also allows you to create instances in container and resolution scopes.
Benefits of The Container
The first benefit is that we can change the implementation in all modules with a single line. This way we achieve the control inversion that the last letter of SOLID talks about.
For example, if we want to change the Logger
implementation in all the places that depend on this interface, we only need to change the binding in the container:
/* The new implementation. */
class AlertLogger implements Logger {
public log(message: string): void {
alert(message);
}
}
/*
* With this implementation we replace the old `ConsoleLogger`.
* This only needs to be done in one place, at the token binding:
*/
container
.bind(TOKENS.logger)
.toInstance(AlertLogger)
.inTransientScope();
In addition, we don't pass dependencies by hand, we don't need to follow the order of enumeration in constructors, the modules become less coupled.
Should You Use DI?
Take some time to understand the potential benefits and tradeoffs of using DI. You will write some infrastructural code, but in return your code will be less coupled, more flexible and easier to test.
Top comments (6)
Are you serious !? This is the best article about DI . Thanks millions
Such a pity that the
Counter.ts
file requires these three lines of code related to brandi injected and the tokens. The class that requires the dependency imho should not have to be aware of anything related to the container (or the implementation of the container, in order to keep the container implementation portable as well).You can move these lines of code to
container.ts
(and it’s better way).A proper container should automatically be able to do this, there should not be any need to having to manually configure a container. Thereby you making it a container's concern (read: the
container.ts
file) to be aware of your application structure. It also thereby makes switching to another container implementation much harder (e.g. one that would in the future support autoloading / wiring). Looking forward to Typescript 5 decorator enhancements!Thank you very much for the helpful explanation
Can you explain how to inject multiple classes into one class? It is on its site, but when I used it, I got an error that the non-optional class could not be connected to the optional while
Hi! Thanks for the question. Can you show a code snippet?