I recently built a small project in Go. I've been working with Java for the past few years and was immediately struck by the lack of momentum behind Dependency Injection (DI) in the Go ecosystem. I decided to try building my project using Uber's
dig
library and was very impressed.
I found that DI helped solve a lot of problems I had encountered in my previous Go applications -- overuse of the
init
function, abuse of globals and complicated application setup.
In this post I'll give an introduction to DI and then show an example application before and after using a DI framework (via the
dig
library).
A Brief Overview of DI
Dependency Injection is the idea that your components (usually
structs
in go) should receive their dependencies when being created. This runs counter to the associated anti-pattern of components building their own dependencies during
initialization. Let's look at an example.
Suppose you have a
Server
struct that requires a
Config
struct to implement its behavior. One way to do this would be for the
Server
to build its own
Config
during initialization.
type Server struct {
config *Config
}
func New() *Server {
return &Server{
config: buildMyConfigSomehow(),
}
}
This seems convenient. Our caller doesn't have to be aware that our
Server
even needs access to
Config
. This is all hidden from the user of our function.
However, there are some disadvantages. First of all, if we want to change the way our
Config
is built, we'll have to change all the places that call the building code. Suppose, for example, our
buildMyConfigSomehow
function
now needs an argument. Every call site would need access to that argument and would need to pass it into the building function.
Also, it gets really tricky to mock the behavior of our
Config
. We'll somehow have to reach inside of our
New
function to monkey with the creation of
Config
.
Here's the DI way to do it:
type Server struct {
config *Config
}
func New(config *Config) *Server {
return &Server{
config: config,
}
}
Now the creation of our
Server
is decoupled from the creation of the
Config
. We can use whatever logic we want to create the
Config
and then pass the resulting data to our
New
function.
Furthermore, if
Config
is an interface, this gives us an easy route to mocking. We can pass anything we want into
New
as long as it implements our interface. This makes testing our
Server
with mock implementations
of
Config
simple.
The main downside is that it's a pain to have to manually create the
Config
before we can create the
Server
. We've created a dependency graph here -- we must create our
Config
first because of
Server
depends on it. In real applications these dependency graphs can become very large and this leads to complicated logic for building all of the components your application needs to do its job.
This is where DI frameworks can help. A DI framework generally provides two pieces of functionality:
-
A mechanism for "providing" new components. In a nutshell, this tells the DI framework what other components you need to build yourself (your dependencies) and how to build yourself once you have those components.
-
A mechanism for "retrieving" built components.
A DI framework generally builds a graph based on the "providers" you tell it about and determines how to build your objects. This is very hard to understand in the abstract, so let's walk through a moderately-sized example.
An Example Application
We're going to be reviewing the code for an HTTP server that delivers a JSON response when a client makes a
GET
request to
/people
. We'll review the code piece by piece. For simplicity sake, it all lives in the same package
(
main
). Please don't do this in real Go applications. Full code for this example can be found
here
.
First, let's look at our
Person
struct. It has no behavior save for some JSON tags.
type Person struct {
Id int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
}
A
Person
has an
Id
,
Name
and
Age
. That's it.
Next let's look at our
Config
. Similar to
Person
, it has no dependencies. Unlike
Person
, we will provide a constructor.
type Config struct {
Enabled bool
DatabasePath string
Port string
}
func NewConfig() *Config {
return &Config{
Enabled: true,
DatabasePath: "./example.db",
Port: "8000",
}
}
Enabled
tells us if our application should return real data.
DatabasePath
tells us where our database lives (we're using sqlite).
Port
tells us the port on which we'll be running our server.
Here's the function we'll use to open our database connection. It relies on our
Config
and returns a
*sql.DB
.
func ConnectDatabase(config *Config) (*sql.DB, error) {
return sql.Open("sqlite3", config.DatabasePath)
}
Next we'll look at our
PersonRepository
. This struct will be responsible for fetching people from our database and deserializing those database results into proper
Person
structs.
type PersonRepository struct {
database *sql.DB
}
func (repository *PersonRepository) FindAll() []*Person {
rows, _ := repository.database.Query(
`SELECT id, name, age FROM people;`
)
defer rows.Close()
people := []*Person{}
for rows.Next() {
var (
id int
name string
age int
)
rows.Scan(&id, &name, &age)
people = append(people, &Person{
Id: id,
Name: name,
Age: age,
})
}
return people
}
func NewPersonRepository(database *sql.DB) *PersonRepository {
return &PersonRepository{database: database}
}
PersonRepository
requires a database connection to be built. It exposes a single function called
FindAll
that uses our database connection to return a list of
Person
structs representing the data in our database.
To provide a layer between our HTTP server and the
PersonRepository
, we'll create a
PersonService
.
type PersonService struct {
config *Config
repository *PersonRepository
}
func (service *PersonService) FindAll() []*Person {
if service.config.Enabled {
return service.repository.FindAll()
}
return []*Person{}
}
func NewPersonService(config *Config, repository *PersonRepository) *PersonService {
return &PersonService{config: config, repository: repository}
}
Our
PersonService
relies on both the
Config
and the
PersonRepository
. It exposes a function called
FindAll
that conditionally calls the
PersonRepository
if the application is enabled.