Go Dependency Injection: A Journey from Beginner to Expert

Leapcell - Mar 5 - - Dev Community

Image description

Leapcell: The Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis

Exploration of Dependency Injection (DI) in Golang

Abstract

This article focuses on the content related to Dependency Injection (DI) in Golang. At the beginning, the concept of DI is introduced with the help of the typical object-oriented language Java, aiming to provide an understanding approach for beginners. The knowledge points in the article are relatively scattered, covering the SOLID principles of object-oriented programming and typical DI frameworks in various languages, etc.

I. Introduction

In the field of programming, dependency injection is an important design pattern. Understanding its application in Golang is of crucial significance for improving code quality, testability, and maintainability. To better explain DI in Golang, we first start with the common object-oriented language Java and introduce the concept of DI.

II. Analysis of the DI Concept

(I) Overall Meaning of DI

Dependency means relying on something to obtain support. For example, people are highly dependent on mobile phones. In the context of programming, when class A uses certain functions of class B, it means that class A has a dependency on class B. In Java, before using the methods of another class, it is usually necessary to create an object of that class (that is, class A needs to create an instance of class B). And the process of handing over the task of creating the object to other classes and directly using the dependencies is the "dependency injection".

(II) Definition of Dependency Injection

Dependency Injection (DI) is a design pattern and one of the core concepts of the Spring framework. Its main function is to eliminate the dependency relationship between Java classes, achieve loose coupling, and facilitate development and testing. To deeply understand DI, we need to first understand the problems it aims to solve.

III. Illustrating Common Problems and the DI Process with Java Code Examples

(I) Problem of Tight Coupling

In Java, if we use a class, the conventional approach is to create an instance of that class, as shown in the following code:

class Player{  
    Weapon weapon;  

    Player(){  
        // Tightly coupled with the Sword class
        this.weapon = new Sword();  
    }  

    public void attack() {
        weapon.attack();
    }
}  
Enter fullscreen mode Exit fullscreen mode

This method has the problem of too tight coupling. For example, the player's weapon is fixed as a sword (Sword), and it is difficult to replace it with a gun (Gun). If we want to change the Sword to a Gun, all the relevant code needs to be modified. When the code scale is small, this may not be a big problem, but when the code scale is large, it will consume a lot of time and energy.

(II) Dependency Injection (DI) Process

Dependency injection is a design pattern that eliminates the dependency relationship between classes. For example, when class A depends on class B, class A no longer directly creates class B. Instead, this dependency relationship is configured in an external xml file (or java config file), and the Spring container creates and manages the bean class according to the configuration information.

class Player{  
    Weapon weapon;  

    // weapon is injected
    Player(Weapon weapon){  
        this.weapon = weapon;  
    }  

    public void attack() {
        weapon.attack();
    }

    public void setWeapon(Weapon weapon){  
        this.weapon = weapon;  
    }  
}   
Enter fullscreen mode Exit fullscreen mode

In the above code, the instance of the Weapon class is not created inside the code but is passed in from the outside through the constructor. The passed-in type is the parent class Weapon, so the passed-in object type can be any subclass of Weapon. The specific subclass to be passed in can be configured in the external xml file (or java config file). The Spring container creates an instance of the required subclass according to the configuration information and injects it into the Player class. The example is as follows:

<bean id="player" class="com.qikegu.demo.Player"> 
    <construct-arg ref="weapon"/>
</bean>

<bean id="weapon" class="com.qikegu.demo.Gun"> 
</bean>
Enter fullscreen mode Exit fullscreen mode

In the above code, the ref of <construct-arg ref="weapon"/> points to the bean with id="weapon", and the passed-in weapon type is Gun. If we want to change it to Sword, we can make the following modification:

<bean id="weapon" class="com.qikegu.demo.Sword"> 
</bean>
Enter fullscreen mode Exit fullscreen mode

It should be noted that loose coupling does not mean completely eliminating coupling. Class A depends on class B, and there is a tight coupling between them. If the dependency relationship is changed to class A depending on the parent class B0 of class B, under the dependency relationship between class A and class B0, class A can use any subclass of class B0. At this time, the dependency relationship between class A and the subclasses of class B0 is loose coupling. It can be seen that the technical basis of dependency injection is the polymorphism mechanism and the reflection mechanism.

(III) Types of Dependency Injection

  1. Constructor Injection: The dependency relationship is provided through the class constructor.
  2. Setter Injection: The injector uses the setter method of the client to inject the dependencies.
  3. Interface Injection: The dependency provides an injection method to inject the dependencies into any client that passes them to it. The client must implement an interface, and the setter method of this interface is used to receive the dependencies.

(IV) Functions of Dependency Injection

  1. Create objects.
  2. Clarify which classes need which objects.
  3. Provide all these objects. If any changes occur to the objects, the dependency injection will investigate, and it should not affect the classes that use these objects. That is, if the objects change in the future, the dependency injection is responsible for providing the correct objects for the classes.

(V) Inversion of Control - The Concept Behind Dependency Injection

Inversion of control means that a class should not statically configure its dependencies but should be configured externally by other classes. This follows the fifth principle of S.O.L.I.D - classes should depend on abstractions rather than specific things (avoid hard coding). According to these principles, a class should focus on fulfilling its own responsibilities rather than creating the objects needed to fulfill its responsibilities. This is where dependency injection comes into play, as it provides the necessary objects for the class.

(VI) Advantages of Using Dependency Injection

  1. Facilitate unit testing.
  2. Since the initialization of the dependency relationship is completed by the injector component, it reduces boilerplate code.
  3. Make the application easier to expand.
  4. Help achieve loose coupling, which is crucial in application programming.

(VII) Disadvantages of Using Dependency Injection

  1. The learning process is a bit complicated, and excessive use may lead to management and other problems.
  2. Many compilation errors will be delayed until runtime.
  3. Dependency injection frameworks are usually implemented through reflection or dynamic programming, which may prevent the use of IDE automation functions, such as "Find References", "Show Call Hierarchy", and safe refactoring.

You can implement dependency injection by yourself, or you can use third-party libraries or frameworks to achieve it.

(VIII) Libraries and Frameworks for Implementing Dependency Injection

  1. Spring (Java)
  2. Google Guice (Java)
  3. Dagger (Java and Android)
  4. Castle Windsor (.NET)
  5. Unity (.NET)
  6. Wire(Golang)

IV. Understanding of DI in Golang TDD

During the use of Golang, many people have many misunderstandings about dependency injection. In fact, dependency injection has many advantages:

  1. A framework is not necessarily required.
  2. It will not overly complicate the design.
  3. It is easy to test.
  4. It can write excellent and general functions.

Take writing a function to greet someone as an example. We expect to test the actual printing. The initial function is as follows:

func Greet(name string) {
    fmt.Printf("Hello, %s", name)
}
Enter fullscreen mode Exit fullscreen mode

However, calling fmt.Printf will print the content to the standard output, and it is difficult to capture it using a testing framework. At this time, we need to inject (that is, "pass in") the dependency of printing. This function does not need to care about where and how to print, so it should receive an interface instead of a specific type. In this way, by changing the implementation of the interface, we can control the printed content and then achieve testing.

Looking at the source code of fmt.Printf, we can see:

// It returns the number of bytes written and any write error encountered.
func Printf(format string, a ...interface{}) (n int, err error) {
    return Fprintf(os.Stdout, format, a...)
}
Enter fullscreen mode Exit fullscreen mode

Inside Printf, it just passes in os.Stdout and calls Fprintf. Further looking at the definition of Fprintf:

func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
    p := newPrinter()
    p.doPrintf(format, a)
    n, err = w.Write(p.buf)
    p.free()
    return
}
Enter fullscreen mode Exit fullscreen mode

Among them, the io.Writer is defined as:

type Writer interface {
    Write(p []byte) (n int, err error)
}
Enter fullscreen mode Exit fullscreen mode

io.Writer is a commonly used interface for "putting data somewhere". Based on this, we use this abstraction to make the code testable and have better reusability.

(I) Writing Tests

func TestGreet(t *testing.T) {
    buffer := bytes.Buffer{}
    Greet(&buffer,"Leapcell")

    got := buffer.String()
    want := "Hello, Leapcell"

    if got != want {
        t.Errorf("got '%s' want '%s'", got, want)
    }
}
Enter fullscreen mode Exit fullscreen mode

The buffer type in the bytes package implements the Writer interface. In the test, we use it as a Writer. After calling Greet, we can check the written content through it.

(II) Trying to Run the Tests

An error occurs when running the tests:

./di_test.go:10:7: too many arguments in call to Greet
have (*bytes.Buffer, string)
want (string)
Enter fullscreen mode Exit fullscreen mode

(III) Writing Minimized Code for the Tests to Run and Checking the Failed Test Output

According to the compiler's prompt, we fix the problem. The modified function is as follows:

func Greet(writer *bytes.Buffer, name string) {
    fmt.Printf("Hello, %s", name)
}
Enter fullscreen mode Exit fullscreen mode

At this time, the test result is:

Hello, Leapcell di_test.go:16: got '' want 'Hello, Leapcell'
Enter fullscreen mode Exit fullscreen mode

The test fails. Notice that the name can be printed, but the output goes to the standard output.

(IV) Writing Enough Code to Make It Pass

Use writer to send the greeting to the buffer in the test. fmt.Fprintf is similar to fmt.Printf. The difference is that fmt.Fprintf receives a Writer parameter to pass the string, while fmt.Printf outputs to the standard output by default. The modified function is as follows:

func Greet(writer *bytes.Buffer, name string) {
    fmt.Fprintf(writer, "Hello, %s", name)
}
Enter fullscreen mode Exit fullscreen mode

At this time, the test passes.

(V) Refactoring

At first, the compiler prompted that a pointer to bytes.Buffer needed to be passed in. Technically, this is correct, but it is not general enough. To illustrate this, we connect the Greet function to a Go application to print content to the standard output. The code is as follows:

func main() {
    Greet(os.Stdout, "Leapcell")
}
Enter fullscreen mode Exit fullscreen mode

An error occurs when running:

./di.go:14:7: cannot use os.Stdout (type *os.File) as type *bytes.Buffer in argument to Greet
Enter fullscreen mode Exit fullscreen mode

As mentioned before, fmt.Fprintf allows passing in the io.Writer interface, and both os.Stdout and bytes.Buffer implement this interface. Therefore, we modify the code to use a more general interface. The modified code is as follows:

package main

import (
    "fmt"
    "os"
    "io"
)

func Greet(writer io.Writer, name string) {
    fmt.Fprintf(writer, "Hello, %s", name)
}

func main() {
    Greet(os.Stdout, "Leapcell")
}
Enter fullscreen mode Exit fullscreen mode

(VI) More about io.Writer

By using io.Writer, the generality of our code has been improved. For example, we can write data to the Internet. Run the following code:

package main

import (
    "fmt"
    "io"
    "net/http"
)

func Greet(writer io.Writer, name string) {
    fmt.Fprintf(writer, "Hello, %s", name)
}

func MyGreeterHandler(w http.ResponseWriter, r *http.Request) {
    Greet(w, "world")
}

func main() {
    http.ListenAndServe(":5000", http.HandlerFunc(MyGreeterHandler))
}
Enter fullscreen mode Exit fullscreen mode

Run the program and visit http://localhost:5000, and you can see that the Greet function is called. When writing an HTTP handler, you need to provide http.ResponseWriter and http.Request. http.ResponseWriter also implements the io.Writer interface, so the Greet function can be reused in the handler.

V. Conclusion

The initial version of the code is not easy to test because it writes data to a place that cannot be controlled. Guided by the tests, we refactor the code. By injecting dependencies, we can control the direction of data writing, which brings many benefits:

  1. Testing the Code: If a function is difficult to test, it is usually because there are hard links of dependencies to the function or the global state. For example, if the service layer uses a global database connection pool, it is not only difficult to test but also runs slowly. DI advocates injecting the database dependency through an interface, so as to control the mock data in the test.
  2. Separation of Concerns: It decouples the location where the data arrives from the way the data is generated. If you feel that a certain method/function undertakes too many functions (such as generating data and writing to the database at the same time, or handling HTTP requests and business logic at the same time), you may need to use the tool of DI.
  3. Reusing Code in Different Environments: The code is first applied in the internal testing environment. Later, if others want to use this code to try new functions, they just need to inject their own dependencies.

Leapcell: The Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis

Finally, I would like to recommend a platform that is most suitable for deploying Golang: Leapcell

Image description

1. Multi-Language Support

  • Develop with JavaScript, Python, Go, or Rust.

2. Deploy unlimited projects for free

  • pay only for usage — no requests, no charges.

3. Unbeatable Cost Efficiency

  • Pay-as-you-go with no idle charges.
  • Example: $25 supports 6.94M requests at a 60ms average response time.

4. Streamlined Developer Experience

  • Intuitive UI for effortless setup.
  • Fully automated CI/CD pipelines and GitOps integration.
  • Real-time metrics and logging for actionable insights.

5. Effortless Scalability and High Performance

  • Auto-scaling to handle high concurrency with ease.
  • Zero operational overhead — just focus on building.

Image description

Explore more in the documentation!

Leapcell Twitter: https://x.com/LeapcellHQ

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .