Confused Spongebob Meme

Dependency Inversion vs. Dependency Injection

Software TestiingSoftware Design Patterns
Published

Are DI and DIP the same thing?

If you're using dependency injection, you may think that means you're conforming to the dependency inversion principle. However, that isn't the case. Both of these address different concerns and it's possible to have one without the other.

Where do we start?

Say you have a UserService that registers a user, creating its own repository to do so.

TypeScript
1type User = {
2 id: string;
3 email: string;
4 password: string;
5}
6
7class PostgresUserRepository {
8 async query(sql: string, values: unknown[]): Promise<Record<string, unknown>[]> {
9 // executes raw SQL against Postgres
10 }
11}
12
13class UserService {
14 async registerUser(email: string, password: string): Promise<User> {
15 const repo = new PostgresUserRepository();
16 const rows = await repo.query(
17 'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
18 [email, password]
19 );
20 return rows[0] as User;
21 }
22}

Does the code work? Yes. However, we have two problems:

  1. UserService can't be tested without running a database
  2. UserService knows that it's talking to SQL/Postgres

To fix these, we employ DI and DIP.

How do we add dependency injection?

The reason UserService requires a running database to test is that the database dependency is implicit. Line 16 shows us declaring the dependency inside the method (i.e. it's hardcoded). As a result, if we want to call the method, we need to make sure the database is running beforehand or it will fail. To address this, we employ dependency injection

Dependency injection is a design pattern where you pass in dependencies instead of hardcoding them. What does this look like in our example?

TypeScript
1// IMPLEMENTING DI
2
3// ...
4
5class UserService {
6 constructor(private repo: PostgresUserRepository) {}
7
8 async registerUser(email: string, password: string): Promise<User> {
9 const rows = await this.repo.query(
10 'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
11 [email, password]
12 );
13 return rows[0] as User;
14 }
15}
16
17const userService = new UserService(new PostgresUserRepository());
18

As you can see, instead of hardcoding PostgresUserRepository in the registerUser method, we make the dependency explicit by passing it in when the class is instantiated (line 20). However, UserService still knows that it's talking to Postgres/SQL. Although we can pass in a different instance of PostgresUserRepository, we can't pass in a different implementation (e.g. a fake in-memory implementation for testing).

Note

All DI controls is how the dependency arrives

How do we apply the dependency inversion principle?

What is DIP?

The dependency inversion principle has two parts:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions shouldn't depend on details. Details should depend on abstractions.

What makes a module high-/low-level is its distance to the business problem. Applying this to our example, UserService is a high-level module because it's closer to what the application actually does, while PostgresUserRepository is our low-level module because it's concerned with how an operation is done.

Is DIP just an interface?

Based on the statements above, one may fall into the trap of thinking DIP is just an interface that sits between your high-level and low-level module:

TypeScript
1interface UserRepository {
2 query: (sql: string, values: unknown[]) => Promise<Record<string, unknown>[]>;
3}
4
5class PostgresUserRepository implements UserRepository {
6 async query(sql: string, values: unknown[]): Promise<Record<string, unknown>[]> {
7 // executes raw SQL against Postgres
8 }
9}
10
11class UserService {
12 constructor(private repo: PostgresUserRepository) {}
13
14 async registerUser(email: string, password: string): Promise<User> {
15 const rows = await this.repo.query(
16 'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
17 [email, password]
18 );
19 return rows[0] as User;
20 }
21}
22
23const userService = new UserService(new PostgresUserRepository());

Here, we have a UserRepository interface and PostgresUserRepository implements it. However, the interface itself is shaped by the details, or the needs of the low-level module (i.e. its method requires an SQL query). If we want to swap out Postgres with another data store for the user repository, the interface breaks.

This is not DIP as it fails to satisfy the second statement. The second statement implies that the abstraction/interface must be shaped around what the business logic, or high-level module, needs. In our UserRepository interface above, both our high-level module (i.e. UserService) and low-level module (i.e. PostgresUserRepository) depend on the abstraction, but it's shaped by what the low-level module needs!

To fix this, we need to identify what the high-level module needs to perform its job. In this case, the registerUser method uses the UserRepository as a way to save the user information. To meet this need, the UserRepository can expose a saveUser method that takes in the information that the business logic cares about saving (e.g. email and password).

file.ts
1interface UserRepository {
2 saveUser: (input: { email: string; password: string }) => Promise<User>;
3}
4
5class PostgresUserRepository implements UserRepository {
6 async saveUser(input: { email: string; password: string }): Promise<User> {
7 // SQL lives here now — hidden behind the interface
8 }
9}
10
11class UserService {
12 constructor(private userRepository: UserRepository) {}
13
14 async registerUser(email: string, password: string): Promise<User> {
15 return await this.userRepository.saveUser({ email, password });
16 }
17}
18
19// Production
20const userService = new UserService(new PostgresUserRepository());
What "inversion" actually means

Notice that in our DIP-compliant code, our high-level module still calls the low-level module. The runtime call direction doesn't change. What does change is who conforms to whom at design time.

Without DIP, our low-level module exposed its needs which the high-level module needed to conform to. With DIP, the high-level module declares the operation it needs, which shapes the interface that the low-level module conforms to.

The low-level module now goes from dictating terms to implementing them, while the high-level module does the opposite.

What does this unlock for us?

With both DI and DIP implemented, we've solved the problems our starting code had:

  1. UserService can now be tested without running a database
  2. UserService doesn't know or care about SQL/Postgres

With both of these problems solved, we can easily swap out Postgres with any implementation! For example, say we're running a test on UserService and we just want a fake, in-memory implementation of UserRepository. This can easily be achieved by having the new implementation satisfy the interface.

TypeScript
1const fakeRepo: UserRepository = {
2 saveUser: async (input) => ({ id: "1", ...input }),
3};
4
5const testService = new UserService(fakeRepo);
6
7// Test runs in milliseconds, no database, no network
8const user = await testService.registerUser("[email protected]", "password123");
9expect(user.email).toBe("[email protected]");

How is it possible to have DI without DIP or vice versa?

It's crucial to reiterate that DI and DIP are independent and you can follow one without conforming to the other. Here's what it would look like.

With DI alone, you're injecting PostgresUserRepository into UserService directly. No abstraction. This allows you to pass different instances of PostgresUserRepository, but now UserService is coupled to Postgres and it's concerned with writing SQL. If you want to swap out to DynamoDB, for example, the entire service needs to be rewritten.

TypeScript
1// DI Alone
2class PostgresUserRepository {
3 async query(
4 sql: string,
5 values: unknown[]
6 ): Promise<Record<string, unknown>[]> {
7 // executes raw SQL against Postgres
8 }
9}
10
11class UserService {
12 constructor(private repo: PostgresUserRepository) {}
13
14 async registerUser(email: string, password: string): Promise<User> {
15 const rows = await this.repo.query(
16 "INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *",
17 [email, password]
18 );
19
20 return rows[0] as User;
21 }
22
23 // other methods here...
24}
25
26// Entrypoint
27const userService = new UserService(new PostgresUserRepository());
28


With DIP alone, your UserRepository interface exists and is implemented by PostgresUserRepository, but now the dependency is declared implicitly in the service. As a result, you can't swap it out with any other implementation without directly editing the service.

TypeScript
1// DIP Alone
2
3interface UserRepository {
4 saveUser: (input: { email: string; password: string }) => Promise<User>;
5}
6
7class PostgresUserRepository implements UserRepository {
8 async saveUser(input: { email: string; password: string }): Promise<User> {
9 // SQL lives here now — hidden behind the interface
10 }
11}
12
13class UserService {
14 async registerUser(email: string, password: string): Promise<User> {
15 const userRepository = new PostgresUserRepository()
16 return await userRepository.saveUser({ email, password });
17 }
18}
19
20// Production
21const userService = new UserService();

By implementing both DI and DIP, we:

  1. Allow the service to accept anything that fits the shape
  2. Define the shape based on business/domain language
  3. Control what fills the shape from outside the service
DIP can be overkill at times

DIP pays off at architectural boundaries. In other words, it's good to use where business logic meets specific technologies (e.g. databases, API's, filesystems). However, there are many instances where implementing DIP is overkill:

1. No business logic to protect - A thin CRUD wrapper that just forwards data to the database doesn't benefit from adding an interface between itself and the database.

2. The dependency is stable pure logic - A math utility or string formatting library is highly unlikely to be swapped. Adding an interface between your service and these kinds of dependencies is unnecessary boilerplate.

3. Both sides always change together - If every change to the low-level module also entails a change to the service that uses it, they're not really separate layers.

The point is that you shouldn't be trying to apply DIP to everything. The most value you'll get from it is when you're applying it to locations where business logic and infrastructure meet.

What did we learn?

In this article, we discussed dependency injection and dependency inversion:

  • DI is a technique that's concerned with how the dependency arrives
  • DIP is a design pattern that controls what type to depend on (i.e. abstraction instead of concrete implementation)

We also covered sample code that doesn't conform to either, transform it into something that does, and what doing so unlocks for us. The benefit of code that implements both is that it's easy to test and it limits the amount of code we need to edit if we want to swap out low-level implementation details.


Last updated

Like