Practice of SOLID in Golang: Dependency Inversion Principle

Publising Date

Author Name

Cesc Nguyen

Categories

development

Introduction

We will learn about SOLID principles with a property that is very effective and understand why it is so important for unit testing during the process of working with Golang - That is Dependency Inversion Principle.

When we do not comply 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.

This is a definition of The dependency Inversion written by Bob,We will rely on it to understand better. First, we all know about the abstraction property in OOP, We will use concepts to uncover the necessary behavior and the hidden details of how to implement them. Second, we need to understand what are the distinctions between high-level and low-level modules?

In the Go context, high-level modules refer to software components that are utilized on top of the application, such as presentation code. Understanding it as a layer that provides real business value to our application.On the other hand,the majority of low-level software components are little code fragments that support the higher-level software.They keep technical information regarding various infrastructure integrations hidden.

For example ,This may be a struct that contains the logic for retrieving data from a database, sending a SQS message, retrieving a value from Redis, or submitting an HTTP request to an external API. So what happens if we break The Dependency Inversion Principle and our high-level component is reliant on a single low-level component? Let's consider the following example:

// 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
}

// domain layer

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

// 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 above code, the high-level component EmailService is defined in the preceding code. This structure, which is part of the application layer, is in charge of delivering emails to newly enrolled clients. The idea is to have a method, SendRegistrationEmail,where the User ID is necessary. It retrieves User from UserRepository in the background, and then sends it to some EmailSender service to send the email.

We will ignore EmailSender for the time being. Let's focus on UserRepository. This structure is part of the infrastructure layer and illustrates how the repository connects with the database. So it seems that the high-level component, EmailService, depends on the low-level component, UserRepository.Actually, we will not be able to initialize the structure if we do not have a database connection.Going against a pattern like this has an immediate impact on unit testing in Go, as shown in the following code:

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 Go, we can not just mock whatever we want. Mocking relies on the use of interfaces, which we can declare a mocked implementation for, but we can not do the same with structs. So we can not mock UserRepository, because it is a struct. In this situation, we will need to develop a lower layer simulation, and we may utilize the SQLMock package for the Gorm connection.However, it is still not reliable or effective method for testing. We must simulate a large number of SQL queries and comprehend the database schema. We must modify the unit test whenever the database is changed.

On the other side, we will have a bigger difficulty with unit testing.What happens if we switch to a different storage system, such as Cassandra ? Specifically, do we intend to have dispersed storage for our customers in the future?If this happens, and we utilize this implementation with 'UserRepository,' there will be a lot of refactor code.

We have now seen that it means for a high-level component to rely on a low-level component. But what about abstractions based on details? Take a look at the code below:

// domain layer

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

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

We should start by developing some interfaces definition with high and low-level components. In this case, we may define UserRepository as a domain layer interface. So, it gives an opportunity to detach EmailService from the database, but still incomplete. Let's take a look at the User struct. It provides a definition for mapping to the database.Even though the struct is already in the domain class, the infrastructure details are still its responsibility. We still break The Dependency Inversion with the new UserRepository (abstraction) interface, which is dependent on the User struct with database schema (details).That interface will undoubtedly change if the database schema is changed. The User struct will remain useable, but the modifications made at the low-level will be preserved.

We gain nothing out of this refactoring in the end, we are still incorrect. With several consequences:

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

Ways to 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.*

Based on the sentences in bold, we will need to build an abstraction(an interface) that both our EmailService and UserRepository components will rely on. Furthermore, no method(e.g Gorm) should be used in such abstraction.Let's take a look at the code below:

// 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{}

/*
type UserRedisRepository struct {

}

type UserCassandraRepository struct {

}
*/

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
}

// domain layer

type User struct {
    // some fields
}

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

// 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
}

With the new structure of the code, we can see the UserRepository interface as a dependency on the User struct, and both are in the domain layer. We now use UserGorm instead of the User struct, which no longer mirrors the database schema. The infrastructure layer lies on top of that layer. it has a ToUser function that corresponds to the User struct. We use UserGorm as a usage detail inside UserDatabaseRepository , as the actual implementation for UserRepository. Only the UserRepository interface and the User object, both of which are in the domain, are used inside the domain and application classes. We may specify as many UserRepository implementations as we like in the infrastructure. UserFileRepository or UserCassandraRepository are two options. The high-level component (EmailService) depends on the abstraction - it contains a field with type UserRepository. However, how does the low-level component depends on the abstraction ?

In Go, structs automatically implement interfaces. That implies we may add a check using a blank identifier instead of adding code where UserDatabaseRepository explicitly implements UserRepository. With this approach, we can control dependencies more easily. Interfaces are dependent on structure, and we may build alternative implementations and include them anytime we want to witness global dependence changes.This is a popular method in any frame work, and it is called The Dependency Injection pattern. There are various libraries in Go, such as from Facebook,Wire, hoặc Dingo. Let's see how it is used in unit testing :

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
}

We may add some basic simulation, GetByIDFunc, as a new type that defines the method from UserRepository where we want to simulate, with this refactoring. This is how you build a function type in Go and give it a method to implement an interface. Our checking will be much more beautiful and efficient from now on. Whatever the situation, we may add alternative implementations for UserRepository and customize the check result.

More Examples

The Dependency Inversion can be broken in other components besides struct. With a pure, independent function, for example, this is conceivable:

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 need to read information for a User. We employ files and JSON format to do this. GetUser reads the contents of the file and converts them into a User . This approach is dependent on the existence of files, and we must rely on files to verify that it is right. As a result, writing tests for this function, such as checking validation rules,is inconvenient if we subsequently add them to the GetUser method. The code depends on too many details, and it would be nice to create some abstractions :

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 the new implementation, we build the GetUser function based on the Reader interface. It is a component of the Go core package , IO. We can define various ways to provide implementation for Reader interface, like GetUserFile, GetUserHTTP, GetDummyUser (where we can use to test GetUser' method)

This method may be used in a variety of situations. We should strive to separate it if we have problems with unit testing or even experience with a dependency cycle in Go by giving the same interface and many implementations as we do.

Conclusion

The Dependency Inversion Principle is the final principle of SOLID, and it represents the letter D in the word SOLID. It highlights the importance of high-level components not relying on low-level components. Instead, all of our components should rely on abstractions, or interface, as the case may be. Abstractions allow us to utilize our code more freely while still ensuring that it is fully tested.

Similar Blog Post

Automation testing is an Automatic technique where the tester writes scripts by own and uses suitable software to test the software. It is basically an automation process of a manual process. Like regression testing, Automation testing also used to test the application from load, performance and stress point of view.

The world has moved to digital with the internet facilitating endless possibilities of exchanging money, data, and goods. Therefore, buying and selling isn’t limited to the physical reach of humans. E-commerce is that space where you can simply buy or sell things online.

Are you looking for outsourcing software development to other countries but don’t know which they are? Read on to learn more.