The Dependency Inversion Principle (DIP), part of the SOLID principles introduced by Robert C. Martin (Uncle Bob) in the 1990s, states that high-level (business logic) and low-level (utilities) modules should depend on a common interfaces. This prevents changes in low-level code from impacting high-level logic, making the system more flexible and maintainable. Simply put, high-level code should receive interfaces and low-level code should satisfy those interfaces.
In traditional OOP languages, dependency injection frameworks are commonly used to manage object creation and wiring, which assists with following the dependency inversion principle. However, since Go isn’t a typical object-oriented language, such frameworks are rarely seen in projects available online.
While several open-source dependency injection frameworks exist for Go, I wanted to create my own, drawing from my experience with Laravel's container. After all, I've been part of the Laravel core team since 2016, and its conventions align with my preferences.
A DI container manages all services (interfaces and types) in a project and knows how to resolve them. It builds this knowledge during application startup, allowing it to provide any required object on demand.
The container we're building handles two types of services:
- Resolved services: Ones that get created on application startup.
- Deferred services: Ones that only get created when requested.
For that, we need our package to export four functions:
NewContainer() *Container
NewContainer
is a constructor for a container.
Register[T any](func() (T, error))
Register
is a generic function that takes an anonymous function that returns a resolved instance of the given type. We'll use Register
for informing the container about the application services and how to resolve them.
Make[T any]() T
Make
is a generic function that returns the given type. We'll use it to resolve a service from the container.
Bootstrap()
Bootstrap
resolves all resolved services and should be called during application startup.
We'll call our package astra
and declare three types:
type Container struct {
mu sync.RWMutex
services map[string]any
bootstrappers []func()
}
type serviceProvider[T any] func(*Container) (T, error)
type containerService[T any] struct {
mu sync.Mutex
instance T
made bool
provider serviceProvider[T]
}
Container
manages a map of all services keyed by a unique name. It also has a slice of bootstrappers. These are anonymous functions that we'll use to resolve all resolved instances on startup.
To ensure concurrent safety, a read-write mutex will be used, allowing the container to register and resolve services correctly in a concurrent environment.
This is a generic type that declares the signature of the functions used to register services. The functions receive a Container
pointer that they may use to request other dependencies from the container.
This generic type represents a single service in the container. It includes a flag to indicate whether the service has already been resolved and stores a copy of the service provider for use when resolving its instance. Additionally, it has its own mutex to ensure concurrent safety during service operations.
Consider calling the Register
and Make
functions in this fashion:
astra.Register[*RedisCache](
container,
func(c *astra.Container) (*RedisCache, error) {
return RedisCache {
db: astra.Make[*redis.Client](container),
}
},
)
The types used with Register
and Make
are *RedisCache
and *redis.Client
, respectively. The functions need to convert each type to a string to be used as key in the services map field (map[string]any
).
To achieve this, we'll create a function in our astra
package named serviceName()
:
func serviceName[T any]() string {
typeForT := reflect.TypeFor[T]() // type of T
if typeForT.Name() != "" { // defined type
return typeForT.PkgPath() + "." + typeForT.Name()
}
if typeForT.Kind() == reflect.Pointer { // pointers
typeForV := typeForT.Elem()
return "*" + typeForV.PkgPath() + "." + typeForV.Name()
}
// all other non-defined types
panic("unsupported type")
}
The process of converting the type to a string begins by using reflection to obtain its type representation in Go. Next, we call the Name()
method to retrieve the type's name. This method returns a string representation of the type, whether it's a built-in type or one defined within the project or package code.
If the name is not empty (the type is defined), we return a string that contains the path of the package in which the type is defined and the name of the type.
For the redis.Client
type, for example, it'll be something like this:
github.com/redis/go-redis/v9.Client
Next, we check the kind of the type, using Kind()
, and check if it's a pointer. In that case we return *
followed by the package path and the type name. For the *redis.Client
type, it'll be something like this:
*github.com/redis/go-redis/v9.Client
For all other non-defined types, like anonymous structs for example, we'll panic as the container isn't designed to work with them.
Our Register
function looks like this:
func Register[T any](c *Container, provider serviceProvider[T]) {
c.mu.Lock()
defer c.mu.Unlock()
c.services[serviceName[T]()] = &containerService[T]{
provider: provider,
mu: sync.Mutex{},
}
c.bootstrappers = append(c.bootstrappers, func() {
Make[T](c)
})
}
We first acquire a write lock on the container instance to ensure no other goroutines are reading or writing. Next, we generate a name for the service from the type and store a new instance of containerService
in the container's services
map. The containerService
stores the provider and has its own mutex.
Finally, we add a function to the container's bootstrappers slice that calls the Make
function with the type and passes the container instance.
For deferred services, we'll define another function:
func RegisterDeferred[T any](c *Container, provider serviceProvider[T]) {
c.mu.Lock()
defer c.mu.Unlock()
c.services[serviceName[T]()] = &containerService[T]{
provider: provider,
mu: sync.Mutex{},
}
}
This one works in the same fashion as Register
except that it doesn't add a boostrapper. As we said, those services won't be resolved on startup.
Under the containerService
type, we'll add a method:
func (s *containerService[T]) make(c *Container) (T, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.made {
return s.instance, nil // already resolved
}
instance, err := s.provider(c) // call the provider
if err != nil {
return instance, err
}
s.instance = instance
s.made = true
return instance, nil
}
This method first checks if there's a resolved instance for that service already and returns it. Otherwise it calls the provider function to resolve the instance and all its dependencies. Finally, it stores the resolved instance and marks the service as made
.
The make
method is not exported because it's only used internally. The exported function Make
is what application code will call to resolve an instance:
func Make[T any](c *Container) T {
c.mu.RLock()
defer c.mu.RUnlock()
name := serviceName[T]()
anything, ok := c.services[name] // get the service
if !ok {
panic(fmt.Sprintf("failed to make service[%s]: not found", name))
}
service, ok := anything.(*containerService[T]) // type assert
if !ok {
panic(fmt.Sprintf("failed to make service[%s]: type assertion failed", name))
}
instance, err := service.make(c) // resolve
if err != nil {
panic(fmt.Sprintf("failed to make service[%s]: %s", name, err))
}
return instance
}
Inside the function body, we extract the service from the container's services
map using its name and use type assertion to ensure we're getting the correct service type. If the type assertion passes, we call the make
method on the service to receive the resolved instance and return it.
Our Bootstrap
method iterates over all the container's bootstrappers and invokes them:
func (c *Container) Bootstrap() {
for _, bootstrapper := range c.bootstrappers {
bootstrapper()
}
c.bootstrappers = nil
}
After resolving all the deferred services, we no longer need the bootstrappers slice. So we set the bootstrappers
field to nil
so the garbage collector can reclaim the memory.
The NewContainer
exported function looks like this:
func NewContainer(size int) *Container {
return &Container{
services: make(map[string]any, size),
mu: sync.RWMutex{},
bootstrappers: make([]func(), 0, size),
}
}
It takes a size
parameter, which we use to initialize the services map and the bootstrappers slice. Setting an appropriate initial size for both helps prevent costly growth operations later on. For more details on how maps and slices grow in Go, check out these guides:
To use the dependency injection container in our Go projects, we begin by adding a service provider for each type we want the container to resolve. Since most applications require a configuration type, we'll start with that:
type Config struct {
RedisAddress string
}
func ConfigProvider(c *astra.Container) (*Config, error) {
return &Config{
RedisAddress: os.Getenv("REDIS_ADDRESS"),
}, nil
}
The ConfigProvider
function creates a new Config
and returns a pointer to it. It uses environment variables to populate the configuration fields (RedisAddress
here for example).
Next, we have a RedisCache
type:
type RedisCache struct {
db *redis.Client
}
func RedisCacheProvider(c *astra.Container) (*RedisCache, error) {
return &RedisCache{
db: astra.Make[*redis.Client](c),
}, nil
}
The RedisCacheProvider
returns a pointer to RedisCache
and uses the container to resolve a pointer to redis.Client
.
Finally, we add a provider for the redis.Client
type:
func RedisProvider(c *astra.Container) (*redis.Client, error) {
config := astra.Make[*Config](c)
return redis.NewClient(&redis.Options{
Addr: config.RedisAddress,
}), nil
}
This provider resolves a Config
instance from the container and uses it to populate the Addr
field of the new client. The same Config
instance can be used to populate any other fields.
Now that we have all the providers in place, let's create a container instance and register our services:
func main() {
container := astra.NewContainer(10)
astra.Register[*Config](container, ConfigProvider)
astra.Register[*RedisCache](container, RedisCacheProvider)
astra.Register[*redis.Client](container, RedisProvider)
container.Bootstrap()
}
During registration, no instances are resolved. Once the container knows how to resolve them, we call Bootstrap
to initialize all instances. If an instance has dependencies, the container will resolve them using their providers if they haven't been resolved yet.
If we wish to defer resolving any of the services, we may use the RegisterDeferred
function instead:
func main() {
container := astra.NewContainer(10)
astra.Register[*Config](container, ConfigProvider)
astra.Register[*RedisCache](container, RedisCacheProvider)
// deferred
astra.RegisterDeferred[*redis.Client](container, RedisProvider)
container.Bootstrap()
}
Here, the *redis.Client
service is deferred. However, since *RedisCache
depends on it, it will be resolved during the bootstrapping phase. If a service has no resolved dependent services, the container will defer its resolution until it's explicitly requested using the Make
function.
Now, imagine we have a Cache
interface in our project that all services use to access a cache instance. We can use an environment variable to determine the appropriate cache driver and resolve the corresponding cache object:
type Cache interface {
Get(key string) (string, error)
}
func CacheProvider(c *astra.Container) (Cache, error) {
if os.Getenv("CACHE_DRIVER") == "redis" {
return astra.Make[*RedisCache](c), nil
} else if os.Getenv("CACHE_DRIVER") == "db" {
return astra.Make[*DBCache](c), nil
}
}
Here, we retrieve the CACHE_DRIVER
environment variable to determine the appropriate cache driver, then call the Make
function to resolve and return an instance of the corresponding cache service.
With that, every time a Cache
service is requested, the container will return a *RedisCache
if the CACHE_DRIVER
environment variable is set to redis
:
cache := astra.Make[Cache](container)
In this article, we've created a good starting point for a dependency injection container. We can extend this container in various ways to support more functionality. Things like:
- Resolving a fresh instance.
- Refreshing a resolved instance.
- Overriding a resolved instance.
- Gracefully terminating all services on application shutdown.
With this implementation, we've embraced the Tao of Go. Our container is kind, ensuring readability and clarity for developers. It's also simple, offering just a handful of exported functions and methods, and humble, leveraging Go’s type safety to help prevent mistakes.
If you wish to read the full source code, you may find it here. And if you wish to explore Go's popular dependency injection frameworks, make sure to check these: