Mastering Code Design: It's All About Dependencies
In our previous article "Mastering Code Design: SOLID Principles Are Crucial For Success", we explored how the SOLID principles lay the foundation for good software design. Now, let's delve deeper into one of the most essential aspects of building systems: managing dependencies.
What Are Dependencies?
Dependencies refer to the relationships between functions, methods, packages, or modules that rely on each other. For example, a service might depend on a repository for data storage or on a logger for logging activities. While dependencies are necessary, if they're not managed well, they can lead to tightly coupled code that's hard to change, test, and extend.
Why Should We Care About Dependencies?
When dependencies are poorly managed, your code might end up with the following problems:
A (Bad) Example of Tight Coupling
Let's look at an example of tight coupling in Go. Imagine we have an Authenticator service that directly interacts with a Database for user storage:
NOTE: Let's ignore the security aspects of the example for now. In practice, it's critical to store passwords securely by hashing and salting them. Directly comparing plain text passwords, as shown here, is solely for illustrative purposes to demonstrate dependency issues.
The output of this program is:
user `john_doe` authentication succeeded
user `jane_doe` authentication failed
In this example, the Authenticator directly depends on the Database struct, and both depend on the User struct. If we want to change the database implementation (e.g., switch to a different data storage system), we'd have to modify Authenticator. This tight coupling reduces flexibility and makes maintenance difficult. Testing the Authenticator in an isolated way is not possible.
Loose Coupling with Interfaces
To solve the problem of tight coupling, we can use interfaces and dependency injection. This approach allows us to define abstractions for dependencies, which can be implemented by concrete types. The key is that higher-level modules (e.g., Authenticator) should not depend on concrete implementations but rather on abstract interfaces.
Step 1: Define Interfaces for Dependencies
First, we define an interface that describes the expected behavior of any storage mechanism.
Authenticator can depend on this interface instead of Database, making the code compliant with the Dependency Inversion Principle (DIP).
Recommended by LinkedIn
Step 2: Implement the Interface in Concrete Types
Next, we ensure that Database implements the UserRepository interface.
Now, Database can be easily replaced or supplemented with other implementations of UserRepository. Also, it can be mocked for testing purposes.
Step 3: Inject Dependencies via Constructor
We refactor Authenticator to accept a UserRepository interface through dependency injection.
This makes Authenticator independent of any concrete implementation of user storage.
Step 4: Use Dependency Injection in the Main Function
In the main function, we inject the dependency:
Final Result
With these changes, the Authenticator module is independent of the concrete implementation of UserRepository, allowing easy swapping between different repositories (e.g., Database, InMemory, File, MockRepository).
Benefits of Loose Coupling in Go
Conclusion: Decoupling for Better Code Design
Mastering dependencies is a key step in building robust and maintainable applications. By following dependency injection and using interfaces, you can create loose coupling between your modules, leading to code that is easier to maintain, test, and extend.
In this article, we have seen how decoupling can significantly improve flexibility and modularity in applications. By adhering to these principles, you can design applications that align with the SOLID principles, promoting better code organization and higher-quality software.
Additionally, for advanced users, exploring dependency injection frameworks or language-specific patterns can further enhance the manageability and scalability of complex projects.