themsaid.com

Building a Dependency Injection Container for Go

2025-02-24

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.

Designing a DI Container

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:

  1. Resolved services: Ones that get created on application startup.
  2. 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.

Declaring Our Types

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]
}

The Container Type

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.

The Service Provider Type

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.

The Container Service Type

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.

Extracting Service Names

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.

Registering Services

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.

Resolving Services

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.

Bootstrapping The Container

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 Constructor

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:

Using the Container

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.

Creating the container & registering services

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.

Working with interfaces

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)

Wrapping Up

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:

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:

Hi, I'm Mohamed Said.

I'm a software engineer, writer, and open-source contributor. You can reach me on Twitter/X @themsaid. Or send me an email at [email protected].