
Dependency Inversion vs. Dependency Injection
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.
1type User = {2 id: string;3 email: string;4 password: string;5}67class PostgresUserRepository {8 async query(sql: string, values: unknown[]): Promise<Record<string, unknown>[]> {9 // executes raw SQL against Postgres10 }11}1213class 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:
UserServicecan't be tested without running a databaseUserServiceknows 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?
1// IMPLEMENTING DI23// ...45class UserService {6 constructor(private repo: PostgresUserRepository) {}78 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}1617const 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).
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:
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- 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:
1interface UserRepository {2 query: (sql: string, values: unknown[]) => Promise<Record<string, unknown>[]>;3}45class PostgresUserRepository implements UserRepository {6 async query(sql: string, values: unknown[]): Promise<Record<string, unknown>[]> {7 // executes raw SQL against Postgres8 }9}1011class UserService {12 constructor(private repo: PostgresUserRepository) {}1314 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}2223const 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).
1interface UserRepository {2 saveUser: (input: { email: string; password: string }) => Promise<User>;3}45class PostgresUserRepository implements UserRepository {6 async saveUser(input: { email: string; password: string }): Promise<User> {7 // SQL lives here now — hidden behind the interface8 }9}1011class UserService {12 constructor(private userRepository: UserRepository) {}1314 async registerUser(email: string, password: string): Promise<User> {15 return await this.userRepository.saveUser({ email, password });16 }17}1819// Production20const userService = new UserService(new PostgresUserRepository());
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:
UserServicecan now be tested without running a databaseUserServicedoesn'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.
1const fakeRepo: UserRepository = {2 saveUser: async (input) => ({ id: "1", ...input }),3};45const testService = new UserService(fakeRepo);67// Test runs in milliseconds, no database, no network8const 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.
1// DI Alone2class PostgresUserRepository {3 async query(4 sql: string,5 values: unknown[]6 ): Promise<Record<string, unknown>[]> {7 // executes raw SQL against Postgres8 }9}1011class UserService {12 constructor(private repo: PostgresUserRepository) {}1314 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 );1920 return rows[0] as User;21 }2223 // other methods here...24}2526// Entrypoint27const 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.
1// DIP Alone23interface UserRepository {4 saveUser: (input: { email: string; password: string }) => Promise<User>;5}67class PostgresUserRepository implements UserRepository {8 async saveUser(input: { email: string; password: string }): Promise<User> {9 // SQL lives here now — hidden behind the interface10 }11}1213class UserService {14 async registerUser(email: string, password: string): Promise<User> {15 const userRepository = new PostgresUserRepository()16 return await userRepository.saveUser({ email, password });17 }18}1920// Production21const userService = new UserService();
By implementing both DI and DIP, we:
- Allow the service to accept anything that fits the shape
- Define the shape based on business/domain language
- Control what fills the shape from outside the service
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.