Ask any web developer, and they can tell you that integrating with other services is the hardest, slowest, most inefficient task they could possibly do. And once the integration is done, the problems keep coming. More often than not, the integrated service:
- has different data types than your service to represent the same thing
- requires forms of authentication that are different from what your application provides by default
- forces your code into poor patterns and difficult abstractions
- changes frequently, which forces your team to update your application.
I’ve experienced this issue on multiple projects. But there is a way to avoid it—the Dependency Inversion Principle (DIP). Following this principle when integrating with someone else’s code can eliminate many power struggles between your codebase and theirs and save your team from needless suffering.
The Dependency Inversion Principle.
DIP is the last principle in the SOLID principles of software design, and my favorite definition is as follows:
“High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.”
Let’s clarify this definition and discuss the dependency injection technique used to align our code with this principle.
“High-level modules” refers to those modules or classes that enforce the rules your application is meant to enforce. For example, new customers must change their password within the first week. Or, perhaps, new bank accounts start with a 0 balance. These are high-level policies that describe the rules the application is there to enforce and do so in unambiguous terms.
Part of the programmer’s job is to define these terms so concretely that a computer can execute them. You might think that your application has no policies, but I can nearly guarantee that this isn’t true. Time and time again, I’ve seen applications where the policies are so commingled with the details around how to deliver these rules to the user that they get entirely missed.
So what’s a “low-level module”? Simply put, this is all the “glue” stuff. How to make an HTTP request, how to connect to a database, what headers to set in your requests, how to authenticate it, and how to parse a string to get some input form’s data. These low-level details don’t need to care about the business rules. (Note: These descriptions of “high-level” and “low-level” are certainly oversimplified. For more detailed descriptions of these terms, check out the reference at the bottom of this post).
Now, what does it mean to have a dependency between these two? Well, the dependency can take many forms, but the most obvious is an import of a module. Consider this TypeScript example:
import PostgresAccountRepo from "PostgresAccountRepo.ts";
function createNewAccount(startingBalance?: Number) {
const accountsRepo = new PostgresAccountRepo();
// This is high-level policy!
if (startingBalance) {
accountsRepo.save(new Account(startingBalance));
} else {
accountsRepo.save(new Account(0));
}
}
In this example, our high level policy is explicitly importing a module which creates a connection to the database. This causes two problems:
- This code is harder to unit test because in order to write a unit test we have to mock out the database module. This also means our unit tests know something of the implementation of the module, and are therefore likely to break if the implementation changes.
- This code mingles the details of how to connect to the database with the high-level policy of how to create a new account.
Another common way I see high-level policy depending on low-level details is when I see database connection code or HTTP request code inlined with the rest of the business logic like in this example:
function createNewAccount(startingBalance?: Number) {
const accountToSave = new Account();
if (startingBalance) {
accountToSave.setBalance(startingBalance);
} else {
accountToSave.setBalance(0);
}
const dbConnection = MyORM.makeConnection();
const dbConnection.insertInto("accounts", accountTosave);
}
If connecting to the database was more complex (perhaps requiring many steps to authenticate) there could be a lot of code between the reader and the essence of the problem—that a new account should have a 0 balance unless a starting balance is specified.
How can we clean up this code to follow the dependency inversion principle? We can use dependency injection. Here’s an example:
// This is an abstraction around low-level detail!
interface AccountRepository {
saveAccount(account: Account) => void
}
function createNewAccount(accountRepo: AccountRepository, startingBalance?: Number) {
// This is high-level policy!
if (startingBalance) {
accountRepo.save(new Account(startingBalance));
} else {
accountsRepo.save(new Account(0));
}
}
Here’s another example in a more functional style and for a different business rule:
// This handles updating the account in the DB,
// a low-level detail.
type UpsertAccountMethod = (account: Account) => void;
function updateBalance(delta: Number, account: Account, upsertAccount: UpsertAccountMethod) {
// This is high-level policy.
const withNewBalance = assoc(account, "balance", account.balance += delta)
if (withNewBalance.balance < 0) {
upsertAccount(applyOverdraftFees(withNewBalance));
return;
}
upsertAccount(withNewBalance)
}
The updateBalance and createNewAccount functions do not have to know how to set up the connection to the database. They simply require anything which fulfills the contract defined by its parameter and it uses that method. Now, when we test these functions we can simply pass anything which fulfills the contract. We can even force that object to throw errors and test those cases. The world is our oyster! And that’s because we’ve successfully extracted the core of what our application does (creating new accounts) from the details of how it does it (connections to the database).
If you want to see how this kind of pattern can be used in a codebase-wide approach, consider looking into Hexagonal Architecture or The Clean Architecture. These architectures both rely on the dependency injection pattern.
It’s time to build your great idea.
Protect and Control Your Codebase With DIP
To illustrate how following DIP by using dependency injection gives you more control over your codebase, we’ll end with a thought experiment.
Consider the task of creating new bank accounts that the previous code example covered earlier. Suppose there’s another team who handles the accounts microservice that you have to interact with to store newly created accounts. In a large organization, you might not have much insight into their progress. Right now you’re on V1 of their API, but tomorrow they could announce that V2 is up and running and you have 2 days to cut over!
If they did announce that their new version was now available, what would have to change if we followed dependency inversion? Just the implementation of AccountRepository. If we didn’t? The createNewAccount method, probably our account data type, and then any other modules that depend on createNewAccount.
By not following DIP, we’ve given the accounts service team control over our codebase! They can force us to make a large change to the code whenever they want simply by changing their API. But, we can protect our codebase and gain control by inverting that dependency so that the blast radius of their changes is as small as possible – preferably limited to a single module.
So, stop letting other teams walk all over your codebase, start inverting those dependencies, and take control!
Sources
Martin, R. C. (2007). Chapter 11: The Dependency Inversion Principle (DIP). In M. Martin (Ed.), Agile Principles, Patterns, and Practices in C#. Prentice Hall.