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();
}
}
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;
}
}
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>
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>
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
- Constructor Injection: The dependency relationship is provided through the class constructor.
- Setter Injection: The injector uses the setter method of the client to inject the dependencies.
- 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
- Create objects.
- Clarify which classes need which objects.
- 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
- Facilitate unit testing.
- Since the initialization of the dependency relationship is completed by the injector component, it reduces boilerplate code.
- Make the application easier to expand.
- Help achieve loose coupling, which is crucial in application programming.
(VII) Disadvantages of Using Dependency Injection
- The learning process is a bit complicated, and excessive use may lead to management and other problems.
- Many compilation errors will be delayed until runtime.
- 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
- Spring (Java)
- Google Guice (Java)
- Dagger (Java and Android)
- Castle Windsor (.NET)
- Unity (.NET)
- 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:
- A framework is not necessarily required.
- It will not overly complicate the design.
- It is easy to test.
- 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)
}
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...)
}
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
}
Among them, the io.Writer
is defined as:
type Writer interface {
Write(p []byte) (n int, err error)
}
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)
}
}
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)
(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)
}
At this time, the test result is:
Hello, Leapcell di_test.go:16: got '' want 'Hello, Leapcell'
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)
}
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")
}
An error occurs when running:
./di.go:14:7: cannot use os.Stdout (type *os.File) as type *bytes.Buffer in argument to Greet
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")
}
(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))
}
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:
- 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.
- 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.
- 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
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.
Explore more in the documentation!
Leapcell Twitter: https://x.com/LeapcellHQ
Top comments (1)
Take this to understand DI "never depend on the implementation, depend on the behavior". This is something I learned from Anthony GG, and that's stuck with me.