Go Dependency Injection: Practical Tips for Better Code

Learn practical tips for implementing dependency injection in Go to improve code maintainability, testability, and overall application structure.

6 septembre 2024

Published

Hugo Mufraggi

Author

6 min read
Go Dependency Injection: Practical Tips for Better Code

Practical Tips For Better Code

After my last article, “Master Python: 5 Tips for Writing Efficient and Maintainable Code,” and Donia asked me to apply the same principles in Go, I decided to take on the challenge!

Today, I’m sharing my top 5 best practices in Go.

Initially, I wanted to write the same article as my Python article. However, I changed my mind and turned this article into a series of tips on improving your dependency injection.

Tip 1: Interface, Interface, Interface

This first tip is crucial. Combined with tips 2 and 3, it will significantly improve the maintainability of your codebase.

Always create an interface to represent each struct that has functions. This is important for several reasons:

  • Defining an interface encapsulates the behavior linked to the struct.
  • It makes testing much more effortless and opens the door to using libraries like GoMock.

In fact, without interfaces in your code, you miss out on fully leveraging Go’s functionality and on using mocks, which are key for effective testing. I’ll write a separate tutorial on how to use GoMock to create a solid testing strategy, but for now, let’s focus on interfaces.

Example

Let’s create an SQL repository with two functions: CreateUser and GetUserLastname.

package main

import "database/sql"

type IUserRepository interface {
    CreateUser(
        firstname string,
        lastname string,
    ) error
    GetUserLastname(firstname string) (*string, error)
}

type userRepository struct {
    db *sql.DB
}

func (r *userRepository) CreateUser(
    firstname string,
    lastname string,
) error {
    // TODO: interact with the db and create the user
    return nil
}

func (r *userRepository) GetUserLastname(firstname string) (*string, error) {
    // TODO: interact with the db and retrieve the information
    return nil, nil
}

func InitUserRepository(db *sql.DB) IUserRepository {
    return &userRepository{db: db}
}

It may look simple, but it’s essential.

From top to bottom:

  • I define the interface with all the function signatures, and it is public (capitalized).
  • I define my struct and link some functions to it. The struct definition is private (lowercase), as is the DB instance. These two choices impact the following: the repository can only be defined within the current package, and the DB variable can only be set once.
  • Finally, I create a function to initialize my struct. This function is public, and it returns the interface type.

Following this pattern, you force developers to call InitUserRepository to initialize the userRepository, ensuring the interface is respected.

You can apply this pattern to any struct that has functions.

Tip 2: Merge your struct and interfaces for better maintainability

I had two functions in my previous example, but imagine your app growing and your user repository implementing 20 or 30 functions. Your file, along with your test file, would be hundreds of lines long. This becomes unmanageable, and doing code reviews becomes painful and confusing.

I have a solution to this problem.

In Go, you can merge your structs and interfaces. By doing so, you can create a separate file for each function. The downside is that the number of files will increase drastically, but the benefits are significant:

  • It reduces the cognitive load: you’re only working with small files that focus on the function you’re dealing with.
  • When working in a team, code reviews become much easier. Developers can quickly understand each part of the code, and the changes are more focused, reducing the risk of mistakes.
  • Your test files only contain tests for the specific function, making them much easier to write and maintain.

Practice

package main

import "database/sql"

/////// file createUser.go

type ICreateUSer interface {
}

type createUSer struct {
 db *sql.DB
}

func (r *createUSer) CreateUser(
 firstname string,
 lastname string,
) error {
 //TODO interact with the db and create suer
 return nil
}

func InitCreateUser(db *sql.DB) ICreateUSer {
 return &createUSer{db: db}
}

/////// file getUser.go

type IGetUSer interface {
 GetUserLastname(firstname string) (*string, error)
}
type getUserLastname struct {
 db *sql.DB
}

func (r *getUserLastname) GetUserLastname(firstname string) (*string, error) {
 //TODO interact with the db and get the information
 return nil, nil
}

func InitGetUserLastname(db *sql.DB) IGetUSer {
 return &getUserLastname{db: db}
}

////// file USerRepository.go

type IUserRepository interface {
 ICreateUSer
 IGetUSer
}

type UserRepository struct {
 IGetUSer
 ICreateUSer
}

func InitUserRepository(db *sql.DB) IUserRepository  {
 createUSer := InitCreateUser(db)
 getUserLastname := InitGetUserLastname(db)
 return &UserRepository{
  ICreateUSer: createUSer,
  IGetUSer:    getUserLastname,
 }
 
}

///// main.go

func main() {
 db:= FAKE_init_db()
 
 userRepository := InitUserRepository(db)
 userRepository.GetUserLastname()
 userRepository.CreateUser()
}

I’ll focus my explanation only on UserRepository.go, as the other two files are similar to what we covered in Part 1.

First, when we look at the IUserRepository interface, I embed my two interfaces, effectively merging them into the IUserRepository. The same approach is used for the UserRepository struct.

In the InitUserRepository function, I need to initialize my two struct functions (createUser and getUser), and then I create an instance of UserRepository.

As you can see in the main program, you can directly access the functions from the UserRepository instance. This pattern closely resembles the composition pattern I’ve written about before — if you’re interested, you can explore it further in my article.

Using this strategy, you gain maintainability and flexibility, making your code more modular. You end up with small, focused code, each containing only the necessary dependencies.

Tip 3: Inject Only Interfaces in Your Dependency Injection

The concept of dependency injection can be thought of as a “matryoshka” of classes or structs, where each layer depends on the next. Let me explain by using an example where we have an API, and the code is organized as follows:

handler → service → repository

  • The handler contains all the HTTP logic and calls the service.
  • The service holds the business logic and calls the repository.
  • The repository manages all interactions with the database.

In this setup, the repository is a dependency of the service, and the service is a dependency of the handler. Each component depends on the one directly below it.

We initialize all dependencies in the main function, gradually building up our logic layer by layer.

Repository Example

// userRepository.go

package main

import "database/sql"

type IUserRepository interface {
 GetUserLastname(firstname string) (*string, error)
}

type UserRepository struct {
 db *sql.DB
}

func (r *UserRepository) GetUserLastname(firstname string) (*string, error) {
 // Implement the logic here
 return nil, nil
}

func InitUserRepository(db *sql.DB) IUserRepository {
 return &UserRepository{db: db}
}

Here, UserRepository depends on sql.DB.

Service Example

// userService.go

package main

type IUserService interface {
 GetUserLastname(firstname string) (*string, error)
}

type userService struct {
 repo IUserRepository
}

func (s *userService) GetUserLastname(firstname string) (*string, error) {
 return s.repo.GetUserLastname(firstname)
}

func InitUserService(repo IUserRepository) IUserService {
 return &userService{repo: repo}
}

It’s crucial here to inject the IUserRepository interface, rather than the concrete UserRepository struct. This opens the door to Inversion of Control (IoC), which makes testing much easier since you can mock the repository during tests.

Handler Example

// userHandler.go

package main

type IUserHandler interface {
 GetUserLastnameHandler(firstname string) string
}

type UserHandler struct {
 service IUserService
}

func (h *UserHandler) GetUserLastnameHandler(firstname string) string {
 // Handler logic
 return ""
}

func InitUserHandler(service IUserService) IUserHandler {
 return &UserHandler{service: service}
}

The UserHandler depends on IUserService.

Main Example

// main.go

func main() {
 config := GetConfig()

 db, err := InitDb(config.DbConfig)
 if err != nil {
  log.Fatal(err)
 }

 // Initialize dependencies
 userRepository := InitUserRepository(db)
 userService := InitUserService(userRepository)
 userHandler := InitUserHandler(userService)

 // Start the application
 app := InitApp(userHandler)
 app.Run()
}

In the main function:

  1. We initialize the repository.
  2. We initialize the service and inject the repository.
  3. We initialize the handler and inject the service.

Why Inject Interfaces, Not Structs?

Injecting interfaces instead of concrete structs provides a key advantage: testability. When you inject an interface, you can use tools like GoMock to generate mock implementations of your interfaces. This allows you to easily create mock behaviors for your repository, for example, when testing service or handler logic, without relying on actual database interactions.

I use GoMock for this purpose. You can explore it here, noting that Uber now maintains the project after some earlier controversy.

With GoMock, you can generate files that simulate your interface’s behavior. This allows you to isolate your tests. For example, when testing your handler, you can mock the repository and avoid actual database calls. This ensures that your repository logic is tested separately and your service and handler logic is tested independently with mock data.

Conclusion

These tips will significantly improve your Go development workflow, making your code more maintainable, testable, and flexible. Dependency injection is a powerful tool when used effectively, and mastering it can elevate the quality of your projects. If you have additional tips or experiences, please drop them in the comments — I’d love to hear your insights! Don’t forget to subscribe to stay updated with my latest articles. Your support means the world to me. Until next time!