Practical SOLID in Golang: Dependency Inversion Principle

- 11 minutes read - 2301 words

We continue our journey through the SOLID principles by presenting the one that has the most significant impact on unit testing in Go: The Dependency Inversion Principle.

Learning a new programming language is often a straightforward process. I often hear: “The first programming language you learn in a year. The second one in a month. The third one in a week, and then each next one in a day.” Saying that is an exaggeration, but it is not too distant from the truth in some cases. For example, jumping to a language relatively similar to the previous one, like Java and C#, can be a straightforward process. But sometimes, switching is tricky, even when we switch from one Object-Oriented language to another. Many features influence such transitions, like strong or weak types, if a language has interfaces, abstract classes, or classes at all. Some of those difficulties we experience immediately after switching, and we adopt a new approach. But some issues we experience later, during unit testing, for example. And then, we learn why The Dependency Inversion Principle is essential, especially in Go.

When we do not respect The Dependency Inversion

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.

Above is the definition of DIP as presented by Uncle Bob in his paper. There are also more details inside his blog. So, how can we understand this, especially in the context of Go? First, we should accept Abstraction as an object-oriented programming concept. We use this concept to expose essential behaviors and hide the details of their implementation.

Second, what are high and low-level modules? In the context of Go, high-level modules are software components used at the top of the application, such as code used for presentation. It can also be code close to the top level, like code for business logic or some use-case components. It is essential to understand it as a layer that provides real business value to our application. On the other hand, low-level software components are mostly small code pieces that support the higher level. They hide technical details about different infrastructural integrations. For example, this could be a struct that contains the logic for retrieving data from the database, sending an SQS message, fetching a value from Redis, or sending an HTTP request to an external API. So, what does it look like when we break The Dependency Inversion Principle, and our high-level component depends on one low-level component?

Let’s examine the following example:

The Infrastructure Layer

type UserRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
	return &UserRepository{
		db: db,
	}
}

func (r *UserRepository) GetByID(id uint) (*domain.User, error) {
	user := domain.User{}
	err := r.db.Where("id = ?", id).First(&user).Error
	if err != nil {
		return nil, err
	}

	return &user, nil
}

The Domain Layer

type User struct {
	ID uint `gorm:"primaryKey;column:id"`
	// some fields
}

The Application Layer

type EmailService struct {
	repository *infrastructure.UserRepository
	// some email sender
}

func NewEmailService(repository *infrastructure.UserRepository) *EmailService {
	return &EmailService{
		repository: repository,
	}
}

func (s *EmailService) SendRegistrationEmail(userID uint) error {
	user, err := s.repository.GetByID(userID)
	if err != nil {
		return err
	}
	// send email
	return nil
}

In the code snippet above, we defined a high-level component, EmailService. This struct belongs to the application layer and is responsible for sending an email to newly registered customers. The idea is to have a method, SendRegistrationEmail, which expects the ID of a User. In the background, it retrieves a User from UserRepository, and later (probably) it delivers it to some EmailSender service to execute email delivery. The part with EmailSender is currently out of our focus. Let’s concentrate on UserRepository instead. This struct represents a repository that communicates with a database, so it belongs to the infrastructure layer. It appears that our high-level component, EmailService, depends on the low-level component, UserRepository. In practice, without defining a connection to the database, we cannot initialize our use-case struct. Such an anti-pattern immediately impacts our unit testing in Go.

Let’s assume we want to test EmailService, as shown in the code snippet below:

Unit Tests for EmailService

import (
	"testing"
	// some dependencies
	"github.com/DATA-DOG/go-sqlmock"
	"github.com/stretchr/testify/assert"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func TestEmailService_SendRegistrationEmail(t *testing.T) {
	db, mock, err := sqlmock.New()
	assert.NoError(t, err)

	dialector := mysql.New(mysql.Config{
		DSN:        "dummy",
		DriverName: "mysql",
		Conn:       db,
	})
	finalDB, err := gorm.Open(dialector, &gorm.Config{})
	
	repository := infrastructure.NewUserRepository(finalDB)
	service := NewEmailService(repository)
	//
	// a lot of code to define mocked SQL queries
	//
	// and then actual test
}

In contrast to some languages, like PHP, we cannot simply mock whatever we would like in Go. Mocking in Go relies on the usage of interfaces, for which we can define a mocked implementation, but we cannot do the same for structs. Therefore, we cannot mock UserRepository, as it is a struct. In such a case, we need to create a mock on the lower level, in this case, on the Gorm connection object, which we can achieve using the SQLMock package.

However, even with this approach, it is neither reliable nor efficient for testing. We need to mock too many SQL queries and have extensive knowledge about the database schema. Any change inside the database requires us to adapt unit tests. Apart from unit testing issues, we face an even bigger problem. What will happen if we decide to switch the storage to something else, like Cassandra, especially if we plan to have a distributed storage system for customers in the future? In such a scenario, if we continue using this implementation of UserRepository, it will lead to numerous refactorings. Now, we can see the implications of a high-level component depending on a low-level one. But what about abstractions that rely on details?

Let’s check the code below:

UserRepository interface

type User struct {
	ID uint `gorm:"primaryKey;column:id"`
	// some fields
}

type UserRepository interface {
	GetByID(id uint) (*User, error)
}

To address the first issue with high and low-level components, we should start by defining some interfaces. In this case, we can define UserRepository as an interface on the domain layer. This step allows us to decouple EmailService from the database to some extent, but not entirely. Take a look at the User struct; it still contains a definition for mapping to the database. Even though such a struct resides in the domain layer, it retains infrastructural details. Our new interface UserRepository (abstraction) still depends on the User struct with the database schema (details), which means we are still breaking the Dependency Inversion Principle (DIP). Changing the database schema will inevitably lead to changes in our interface. This interface may still use the same User struct, but it will carry changes from a low-level layer.

In the end, with this refactoring, we haven’t achieved much. We are still in the wrong position, and this has several consequences:

  1. We cannot effectively test our business or application logic.
  2. Any change to the database engine or table structure affects our highest levels.
  3. We cannot easily switch to a different type of storage.
  4. Our model is strongly coupled to the storage layer.

So, once again, let’s refactor this piece of code.

How we do respect The Dependency Inversion

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 revisit the original directive for The Dependency Inversion Principle and focus on the bold sentences. They provide us with some guidance for the refactoring process. We need to define an abstraction (an interface) that both of our components, EmailService and UserRepository, will depend on. This abstraction should not be tied to any technical details, such as the Gorm object.

Let’s take a look at the following code:

The Infrastructure Layer

type UserGorm struct {
	// some fields
}

func (g UserGorm) ToUser() *domain.User {
	return &domain.User{
		// some fields
	}
}

type UserDatabaseRepository struct {
	db *gorm.DB
}

var _ domain.UserRepository = &UserDatabaseRepository{}

func NewUserDatabaseRepository(db *gorm.DB) UserRepository {
	return &UserDatabaseRepository{
		db: db,
	}
}

func (r *UserDatabaseRepository) GetByID(id uint) (*domain.User, error) {
	user := UserGorm{}
	err := r.db.Where("id = ?", id).First(&user).Error
	if err != nil {
		return nil, err
	}

	return user.ToUser(), nil
}

In the new code structure, we observe the UserRepository interface as a component that relies on the User struct, both of which reside within the domain layer. The User struct no longer directly reflects the database schema; instead, we use the UserGorm struct for this purpose, which belongs to the infrastructure layer. The UserGorm struct provides a method called ToUser, which facilitates the mapping to the actual User struct.

The Domain Layer

type User struct {
	// some fields
}

type UserRepository interface {
	GetByID(id uint) (*User, error)
}

In this setup, UserGorm serves as part of the implementation details within UserDatabaseRepository, which acts as the concrete implementation for UserRepository. Within the domain and application layers, our dependencies are exclusively on the UserRepository interface and the User Entities, both originating from the domain layer. Within the infrastructure layer, we can define as many implementations for UserRepository as needed, such as UserFileRepository or UserCassandraRepository.

The Application Layer

type EmailService struct {
	repository domain.UserRepository
	// some email sender
}

func NewEmailService(repository domain.UserRepository) *EmailService {
	return &EmailService{
		repository: repository,
	}
}

func (s *EmailService) SendRegistrationEmail(userID uint) error {
	user, err := s.repository.GetByID(userID)
	if err != nil {
		return err
	}
	// send email
	return nil
}

The high-level component (EmailService) depends on an abstraction, as it contains a field with the type UserRepository. Now, let’s explore how the low-level component depends on this abstraction.

In Go, structs implicitly implement interfaces, so there’s no need to explicitly add code indicating that UserDatabaseRepository implements UserRepository. However, we can include a check with a blank identifier to ensure this relationship. This approach allows us to have better control over our dependencies. Our structs depend on interfaces, and if we ever need to change our dependencies, we can define different implementations and inject them. This technique aligns with the Dependency Injection pattern, a common practice in various frameworks.

In Go, several DI libraries are available, such as the one from Facebook, Wire, or Dingo.

Now, let’s examine how this refactoring affects our unit testing.

Unit Tests for EmailService

import (
	"errors"
	"testing"
)

type GetByIDFunc func(id uint) (*User, error)

func (f GetByIDFunc) GetByID(id uint) (*User, error) {
	return f(id)
}

func TestEmailService_SendRegistrationEmail(t *testing.T) {
	service := NewEmailService(GetByIDFunc(func(id uint) (*User, error) {
		return nil, errors.New("error")
	}))
	//
	// and just to call the service
}

Following this refactoring, we can easily create a straightforward mock using a new type, GetByIDFunc. This type defines a function signature that matches the GetByID method of the UserRepository interface. In Go, it’s a common practice to define a function type and assign a method to it in order to implement an interface. This approach greatly improves the elegance and efficiency of our testing process. We now have the flexibility to inject different UserRepository implementations for various use cases and precisely control the test outcomes.

Some more examples

Breaking the Dependency Inversion Principle (DIP) isn’t limited to structs alone; it can also occur with standalone, independent functions. For instance:

Breaking DIP in Functions

type User struct {
	// some fields
}

type UserJSON struct {
	// some fields
}

func (j UserJSON) ToUser() *User {
	return &User{
		// some fields
	}
}

func GetUser(id uint) (*User, error) {
	filename := fmt.Sprintf("user_%d.json", id)
	data, err := ioutil.ReadFile(filename)
	if err != nil {
		return nil, err
	}
	
	var user UserJSON
	err = json.Unmarshal(data, &user)
	if err != nil {
		return nil, err
	}
	
	return user.ToUser(), nil
}

We aim to retrieve data for a User, and for this task, we utilize files in JSON format. The GetUser method reads from a file and converts the file content into a User object. However, this method is tightly coupled with the presence of these files, making it challenging to write effective tests. This is especially true when we introduce additional validation rules to the GetUser method at a later stage. Our code’s heavy reliance on specific details creates testing difficulties, emphasizing the need for abstractions:

Respecting DIP in Functions

type User struct {
// some fields
}

type UserJSON struct {
	// some fields
}

func (j UserJSON) ToUser() *User {
	return &User{
		// some fields
	}
}

func GetUserFile(id uint) (io.Reader, error) {
	filename := fmt.Sprintf("user_%d.json", id)
	file, err := os.Open(filename)
	if err != nil {
		return nil, err
	}

	return file, nil
}

func GetUserHTTP(id uint) (io.Reader, error) {
	uri := fmt.Sprintf("http://some-api.com/users/%d", id)
	resp, err := http.Get(uri)
	if err != nil {
		return nil, err
	}

	return resp.Body, nil
}

func GetDummyUser(userJSON UserJSON) (io.Reader, error) {
	data, err := json.Marshal(userJSON)
	if err != nil {
		return nil, err
	}

	return bytes.NewReader(data), nil
}

func GetUser(reader io.Reader) (*User, error) {
	data, err := ioutil.ReadAll(reader)
	if err != nil {
		return nil, err
	}

	var user UserJSON
	err = json.Unmarshal(data, &user)
	if err != nil {
		return nil, err
	}

	return user.ToUser(), nil
}

With this revised implementation, the GetUser method depends on an instance of the Reader interface. This interface is part of the Go core package, IO. Using this approach, we can define various methods that provide implementations for the Reader interface, such as GetUserFile, GetUserHTTP, or GetDummyUser (which is useful for testing the GetUser method). This strategy can be employed in various scenarios to address challenges related to unit testing or dependency cycles in Go. By introducing interfaces and multiple implementations, we can achieve effective decoupling.

Conclusion

The Dependency Inversion Principle is the last SOLID principle, represented by the letter D in the word SOLID. This principle asserts that high-level components should not rely on low-level components. Instead, all our components should be built on abstractions, specifically interfaces. These abstractions enable us to use our code with greater flexibility and to conduct thorough testing.

Useful Resources

comments powered by Disqus