SOLID Go: Dependency Inversion Principle
Quote: “A. High-Level modules should not depend upon low level modules. Both should depend upon abstractions. B. Abstractions should not depend upon details. Details should depend upon abstractions.”
SRC: Wikipedia on the Dependency Inversion Principle
The Dependency Inversion Principle (DIP) recommends designing the direction of the dependency flow differently from the intuitive way. To be more precise: When there is more than one building block, the Dependency Inversion Principle suggests a recipe for a building block or package, how to depend on each other in a way that your code becomes more flexible and maintainable. Does it sound exotic? If you are writing code daily, chances are good that you are already a regular user of this principle without knowing it. Do you want to know why? Then let’s have a look at it!
First Rule of Dependency Inversion Principle
The first rule of the Dependency Inversion Principle says that high-level modules (= interfaces/structs from the more outer layers of your architectural onion) should never directly import modules from lower levels (= interfaces/structs the more inner layers of your architectural onion). Instead, both modules should rely on abstraction to work with each other.
A concrete negative example: A UserService struct directly imports UserDatabaseRepository struct from another package and accesses an instance of UserDatabaseRepository directly as a function parameter or struct field. This setup would be considered a wrong/missing application of the DIP because the dependency/import flows from a higher-level module to a lower-level module without abstractions between the two.
Not using an interface creates trouble for us gophers because this design also violates the Open Closed Principle and the Liskov Substitution Principle. Golang is not able to extend structs by classical type inheritance. It uses struct embedding instead. The substitution of type hierarchies is also impossible. If you want to achieve something similar to type hierarchies, gophers like us need to use an interface.
BTW: For more details on these challenges, we recommend reading the SOLID Go articles on the Open Closed Principle and the Liskov Substitution Principle in this blog.
Long story short: A code design like the one below violates the Dependency Inversion Principle.
Dependency Inversion Principle is not applied yet: UML Diagram showing a “normal” relation between service and repository. Dependency Inversion Principle is violated.
Instead, the DIP’s first rule recommends that the UserService (S) import an interface type GenericUsersRepository (I). Usually, the interface resides underneath the same package as the concrete lower-level implementation UserDatabaseRepository (R). Struct S will import I from the other package and therefore does not care about the concrete type or implementation of R. R can potentially be UserDatabaseRepository or UserApiServiceRepository, two or even more completely different data sources. But S couldn’t care less because S only relies on the contract.
Introducing this abstraction allows you to replace R with whatever concrete implementation you want: Mocking your database access? Not a problem. Switching to MongoDB? A lot less effort in refactoring, as long as your interface definitions have a good software design!
Dependency Inversion Principle’s first rule applied: UML Diagram showing extending the before example by adding an interface
A code design like this is good for gophers like us because now we are compliant again with the Liskov Substitution Principle. Moreover, introducing additional between abstraction software layers is a good rule of thumb to increase your codebase maintainability and extendability. But what exactly in this example gives inverses the dependency here? Because the direction of dependency imports between S to R and S to I is still the same, right?
A very clever gopher you are! At this point, you only introduced an additional dependency pointing from R to I. So the outcome is additional complexity and still no dependency inversion.
Good point! The DIP unfolds its full potential when your code follows the first and the second rule. Therefore, designing your code the way we did in this section is a prerequisite for the upcoming third section. But before that, let’s have an optional refresher on what interfaces are about.
Refresher: A Mental Model for Interfaces Building Blocks
Previously you learned about introducing an interface instead of directly relying on the concrete implementation of a struct. Let’s have a quick recap and teach you a mental model that you can use to understand the nature of interfaces better.
Interfaces are very close to what a legal contract represents in your daily life: Every time you join a gym, signup for an apartment lease or start a new job, there is a contract involved. A business contract regulates an agreement between at least two parties. The agreement fixates on critical touchpoints, details on exchanged goods and services and at least basic agreements on potentially positive and negative events which might occur during the “lifetime” of the business relation.
One noticeable aspect of contracts in real life that makes them equal to interfaces in computer programming is that the involved parties don’t care much how the other is accomplishing and keep their promise as long as the outcome is valid. Let’s say you sign up for a gym. The company does not care where you get the money from as long as you pay your membership fee on time. Is it a loan? Is it cash from crypto-money? Is it monthly income from hard-earned work? They don’t care about the concrete implementation details as long as the promised behaviour yields the expected outcome. That’s exactly the same with interfaces in computer programming and what the SOLID principles want us to understand in the first place: Use interfaces, build upon abstractions, and never promise outcomes but deliver something else.
In a consumer service situation like the gym, the gym company usually offers a contract. Having authority over the contract simplifies administrational overhead in the organisation.
To put it into software design terms: Typically, the consumer has to accept the contract of the providing party. So when you have the higher level struct S as the consuming party, S must take whatever definitions lower level interface I is defining and R is delivering through I. So when terms and service descriptions in I are changed, R needs to catch up. If the additions introduce breaking changes, S can accept the new terms by catching up with the version of I, or choose not to accept I. The latter, of course, is unacceptable with computer programming because this means your code will not compile.
Question: Can you come up with examples of real-life scenarios? What happens when the gym contract or terms & services are changed? What will be the possible outcomes?
So you can see: Same as with real-life contracts, in software development, usually one party has the authority over the “terms of service” of the interface. Most likely, a package underneath the interface is residing. Every consumer (S) and provider (R) of the interface definitions need to stick to the interface signature. It is also typical to move the provider source code file somewhere next to the location of the interface source code file. So this is why designs using an interface usually have the notion of forcing a convention on the consumer of an interface. The provider of a functionality dictates the rules. The consumer needs to care for all different variances of different interfaces to various functionality providers. The dependency inversion principle wants us to design things the other way around.
Second Rule of Dependency Inversion Principle
From the perspective of S, you can replace “higher level” with “abstraction” and “lower level” with “implementation details” because an abstraction in object-orientated programming means that you are hiding or “wrapping” away one or more building blocks from the even more higher-level. So struct S is adopting R through I, but from outside S, no one can tell what is happening inside S. The details are abstracted away.
Which brings us finally to the second rule of the Dependency Inversion Principle:
Abstractions should not depend upon details. Details should depend upon abstractions.
Put differently: S should not depend upon I of R. R should depend upon I of S. By doing so, the lower-level concept aligns with the needs of the higher-level concept.
So basically, the consuming party S brings their own contract/interface. This results in the higher level concept having fewer import dependencies and also fewer lines of code, handling different variants of a service provider. Therefore the relationship of dependency gets reversed.
Dependency Inversion Principle’s first rule applied: UML Diagram showing extending the before example by adding an interface
Benefits of the Dependency Inversion Principle
So what’s this fuzz all about? Your advantage when using the Dependency Inversion Principle as a Golang developer is that you can easily replace or substitute concrete implementations on the consumer side. Here are some scenarios where the Dependency Inversion Principle will help a lot:
- You have a complex package structure that needs to replace lower-level code packages with less refactoring effort in the higher-level packages.
- You need to abstract your service layer code away from the concrete database driver to have the option to easily switch your persistence strategy.
- You are programming an IoT Hub or a similar platform to orchestrate and work with a lot of diffent functionlity providers as edge nodes of the network. All edge nodes share the same basic set of functionality (f.e. health check, power on, power off, update firmware)
- You want to be able to flexibly inject mock objects into your services for isolated unit testing without any integrational aspects.
- You want to use an Inversion Of Control container in your application to change the application behaviour before or during runtime dynamically.
In Golang, the official package database/sql from the Golang standard library provides an abstract interface for SQL database libraries. Any SQL database driver vendor for Postgres, MariaDB, SQLite and others imports the interface from the “database/sql” package, and as long as your code adheres to the interface as strictly, this provides a great deal of flexibility in the adaptability between the two layers.
Conclusion
In Golang, the Dependency Inversion Principle has a place because Golang knows the concepts packages, structs and interfaces.
The Dependency Inversion Principle is an important principle which can improve the flexibility and maintainability of every Golang codebase. Since the DIP is heavily based upon the concept of interfaces, and interfaces are fundamentally crucial to good Golang code, you can consider the Dependency Inversion Principle a code design practice worth understanding in detail.