DEV Community

Cover image for Mastering Code Design: It's All About Dependencies
Felipe Rubo
Felipe Rubo

Posted on

Mastering Code Design: It's All About Dependencies

In my previous article "Mastering Code Design: SOLID Principles Are Crucial For Success", I 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:

  • Tightly Coupled Code: Components that depend directly on each other are hard to modify or extend.
  • Testing Challenges: If code is tightly coupled, unit testing becomes harder because you can't easily isolate the component you want to test.
  • Reduced Flexibility: Modifying one part of your system can unexpectedly affect other parts, increasing the risk of bugs and making your codebase harder to maintain.

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.

package main

import "fmt"

type User struct {
    Username string
    Password string
}

type Database struct {
}

func (db *Database) QueryUserByUsername(username string) (User, error) {
    //simulate query in database
    return User{Username: username, Password: "password123"}, nil
}

type Authenticator struct {
    db *Database
}

func (a *Authenticator) Authenticate(username string, password string) bool {
    user, err := a.db.QueryUserByUsername(username)
    if err != nil {
        return false
    }
    if user.Username == username && user.Password != password {
        return false
    }
    return true
}

func main() {
    db := &Database{}
    auth := &Authenticator{db: db}

    if auth.Authenticate("john_doe", "password123") {
        fmt.Println("user `john_doe` authentication succeeded")
    } else {
        fmt.Println("user `john_doe` authentication failed")
    }

    if auth.Authenticate("jane_doe", "passwordABC") {
        fmt.Println("user `jane_doe` authentication succeeded")
    } else {
        fmt.Println("user `jane_doe` authentication failed")
    }
}
Enter fullscreen mode Exit fullscreen mode

The output of this program is:

user `john_doe` authentication succeeded
user `jane_doe` authentication failed
Enter fullscreen mode Exit fullscreen mode

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.

type UserRepository interface {
    GetPasswordByUsername(username string) (string, error)
}
Enter fullscreen mode Exit fullscreen mode

Authenticator can depend on this interface instead of Database, making the code compliant with the Dependency Inversion Principle (DIP).

Step 2: Implement the Interface in Concrete Types

Next, we ensure that Database implements the UserRepository interface.

type Database struct {
}

func (db *Database) GetPasswordByUsername(username string) (string, error) {
    user, err := db.QueryUserByUsername(username)
    if err != nil {
        return "", err
    }
    return user.Password, nil 
}
Enter fullscreen mode Exit fullscreen mode

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.

type Authenticator struct {
    repo UserRepository
}

func NewAuthenticator(repo UserRepository) *Authenticator {
    return &Authenticator{repo: repo}
}
Enter fullscreen mode Exit fullscreen mode

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:

package main

func main() {
    db := &Database{}
    auth := NewAuthenticator(db)
    ...
}
Enter fullscreen mode Exit fullscreen mode

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).

package main

import "fmt"

type UserRepository interface {
    GetPasswordByUsername(username string) (string, error)
}

type User struct {
    Username string
    Password string
}

type Database struct {
}

func (db *Database) QueryUserByUsername(username string) (User, error) {
    //simulate query in database
    return User{Username: username, Password: "password123"}, nil
}

func (db *Database) GetPasswordByUsername(username string) (string, error) {
    user, err := db.QueryUserByUsername(username)
    if err != nil {
        return "", err
    }
    return user.Password, nil 
}

type Authenticator struct {
    repo UserRepository
}

func NewAuthenticator(repo UserRepository) *Authenticator {
    return &Authenticator{repo: repo}
}

func (a *Authenticator) Authenticate(username string, password string) bool {
    storedPassword, err := a.repo.GetPasswordByUsername(username)
    if err != nil {
        return false
    }
    if storedPassword != password {
        return false
    }
    return true
}

func main() {
    db := &Database{}
    auth := NewAuthenticator(db)

    if auth.Authenticate("john_doe", "password123") {
        fmt.Println("user `john_doe` authentication succeeded")
    } else {
        fmt.Println("user `john_doe` authentication failed")
    }

    if auth.Authenticate("jane_doe", "passwordABC") {
        fmt.Println("user `jane_doe` authentication succeeded")
    } else {
        fmt.Println("user `jane_doe` authentication failed")
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefits of Loose Coupling in Go

  • Flexibility: Easily swap implementations of dependencies (e.g., replacing a database with an in-memory store).
  • Testability: Testing becomes easier because you can inject mock implementations of the interfaces and isolate the component under test.
  • Maintainability: Isolated modules ensure changes in one do not adversely affect others, provided they adhere to defined interfaces.
  • Scalability: Supports system growth through easy addition of new interface implementations without modifying existing code.
  • Code Responsibility: Encourages alignment with SOLID principles, especially the Dependency Inversion Principle, leading to better-organized architecture.
  • Bugs Repellent: Well-structured and maintainable code tends to have fewer bugs because it is simpler and more comprehensively covered by unit tests, thus naturally resisting bug introduction.

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.

Image of Datadog

The Future of AI, LLMs, and Observability on Google Cloud

Datadog sat down with Google’s Director of AI to discuss the current and future states of AI, ML, and LLMs on Google Cloud. Discover 7 key insights for technical leaders, covering everything from upskilling teams to observability best practices

Learn More

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay