Dependency injection in Go with uber-go/fx

Dependency injection in Go

Since I've been working on Go projects, the main issue that arises at every initialization is the management of dependency injection.

In a recent exchange I was told about the uber-go/fx library and I must say I really liked their approach.

Let's see how you can use it on your projects.

Package main

First of all, the entry point of your project, in Go, is the main package and its main() function.

Reading the code for this function should be simple, short and clear. You need to be able to understand how your application works by reading a dozen lines of code, and the library meets this need perfectly.

I have developed an application that allows me to define a configuration, to log elements in output and mainly : to execute an HTTP API.

Here is what the main file of this application looks like:

package main

import (
	"context"

	"github.com/eko/http-api/config"
	"github.com/eko/http-api/internal/http"
	"github.com/eko/http-api/internal/log"
	"go.uber.org/fx"
)

func main() {
    ctx := func() context.Context {
        return context.Background()
    }

    fx.New(
		fx.Provide(
			fx.Annotate(ctx, fx.As(new(context.Context))),
			config.New,
			log.NewLogger,
			http.NewServer,
		),
		fx.Invoke(http.HTTPHandler),
		fx.NopLogger,
	).Run()
}

Reading this, we notice the following:

  • Let's start with a special feature: we annotate via fx.Annotate() an instance of *context.emptyCtx{} telling it that this instance will be used when my services request a context.Context interface,
  • We then define the constructor functions of our different objects config, log and http,
  • Then, when the application is executed (here via the Run method), the http.HTTPHandler handler will be executed.

Finally fx.NopLogger is optional here. By default, the library logs information about the initialization sequence of the dependencies, you can add this line to not log them or give it your own logger instance:


[Fx] PROVIDE	context.Context <= fx.Annotate(main.main.func1(), fx.As([[context.Context]])
[Fx] PROVIDE	*config.Base <= github.com/eko/http-api/config.New()
[Fx] PROVIDE	*zerolog.Logger <= github.com/eko/http-api/internal/log.NewLogger()
[Fx] PROVIDE	*http.Server <= github.com/eko/http-api/internal/http.NewServer()
[Fx] PROVIDE	fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE	fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE	fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] INVOKE		github.com/eko/http-api/internal/http.HTTPHandler()
[Fx] HOOK OnStart		github.com/eko/http-api/internal/http.NewServer.func1() executing (caller: github.com/eko/http-api/internal/http.NewServer)
[Fx] RUNNING

A good tool that will allow you to debug your dependency injections if you encounter problems.

How the library works

Injection of dependencies in arguments

Obviously, each of the constructor methods declared here take arguments, which are automatically handled by the dependency injection.

For example, if we look at the declaration of the configuration constructor, it takes a context.Context as an argument and returns an instance of *config.Base which can then be injected into other constructors:

package config

import (
	"context"
	"time"

	"github.com/heetch/confita"
	"github.com/heetch/confita/backend/env"
	"github.com/heetch/confita/backend/flags"
)

type Base struct {
	Server
}

type Server struct {
	HTTPAddr string `config:"HTTP_ADDR"`
}

// New loads configuration over different backends and returns values.
func New(ctx context.Context) *Base {
	cfg := &Base{
		Server: Server{
			HTTPAddr: ":8000",
		},
	}

	loader := confita.NewLoader(env.NewBackend(), flags.NewBackend())

    if err := loader.Load(ctx, cfg); err != nil {
		panic(err)
	}

	return cfg
}

Now let's look at the declaration of our HTTP server:

func NewServer(lc fx.Lifecycle, cfg *config.Base, logger *zerolog.Logger) *Server {
	server := &Server{
		Server: &http.Server{
			Addr: cfg.HTTPAddr,
		},
	}

	lc.Append(fx.Hook{
		OnStart: func(context.Context) error {
			logger.Info().Msg("Starting HTTP server")
			go server.ListenAndServe()
			return nil
		},
		OnStop: func(ctx context.Context) error {
			logger.Info().Msg("Stopping HTTP server")
			return server.Shutdown(ctx)
		},
	})

	return server
}

As said before, the dependency injection is well in place and the three arguments (fx.Lifecycle, *config.Base and *zerolog.Logger) will be automatically injected by the library.

Lifecycle with fx.Lifecycle

We haven't mentioned fx.Lifecycle yet and this is the opportunity: it can be injected when you need to define hooks on two events: OnStart and OnStop. In other words, functions to be executed at startup and shutdown of the application.

In this case, we want our HTTP server to be started and stopped correctly.

Invocation with fx.Invoke

Once your dependencies defined via fx.Provide() are ready, and only when the application starts, (via the Run() methods or by handling the Start() and Stop() yourself), the methods declared in the fx.Invoke() section will be executed.

In our case, these are simply the handlers of our HTTP server, but you can of course declare multiple arguments if you are running, for example, an HTTP server and a gRPC server at the same time.

func HTTPHandler(cfg *config.Base, server *Server) {
	r := chirouter.NewWrapper(chi.NewRouter())

    // ...
    r.Method(http.MethodPost, "/user", CreateUserHandler())

	server.Handler = r
}

Going further

We have seen the main principles of using the uber-go/fx library but there are obviously other methods that will allow you to carry out the various use-cases that you may encounter in your projects.

fx.Supply

For example, fx.Supply() allows you to specify values of already built instances to use them in your arguments:

var a, b, c = &TypeA{}, TypeB{}, &TypeC{}

fx.Supply(a, b, fx.Annotated{Target: c})

fx.Module

On larger projects, you can also further break down the declaration of your services into modules, for example:

redis := fx.Module(
    "redis",
    fx.Provide(
        logger.New,
        redis.NewClient,
    ),
    fx.Invoke(cleaner.New),
)

dynamodb := fx.Module(
    "dynamodb",
    fx.Provide(dynamodb.NewClient),
    fx.Invoke(persister.New),
)

app := fx.New(
    redis,
    dynamodb,
    fx.Invoke(func(l *Logger, redisClient *redis.Client, dynamoClient *dynamodb.Client) {
        // Do something.
    }),
)

fx.In et fx.Out

You can also inject dependencies by using tags on your struct with these functions.

Here is an example:

type serverManager struct {
    fx.Out

    httpServer1 *http.Server `name:"httpServer1"`
    httpServer2 *http.Server `name:"httpServer2"`
}

newServerManager := func() *serverManager {
    return &serverManager{
        httpServer1: &http.Server{},
        httpServer2: &http.Server{},
    }
}

type server2Handlers struct {
    fx.In

    httpServer2 *http.Server `name:"httpServer2"`
}

fx.New(
    fx.Provide(newServerManager),
    fx.Invoke((func(handlers server2Handlers) {
        // Do something.
    })),
)

The tags are optional here but they allow us to illustrate the following method.

fx.Replace

In some cases, you may also need to pass a version of the object rather than another, mainly in the case of interfaces.

For this, fx.Replace() allows to declare a new version of an object and also to specify a tag parameter to select the right object:

fx.Replace(
    fx.Annotate(&http.Server{Addr: ":3000"}, fx.ResultTags(`name:"httpServer1"`)),
)

Conclusion

After having seen a lot of "home-made" solutions, this library can really be an asset that will avoid you having to manage dependency injection in your projects yourself, thus saving you a good number of lines of service declaration code.

Learning the notions of this library has a small entry cost but the methods are quite easy to read and if you have several projects: standardizing them on the same library will allow your developers to enter the projects even more quickly.

The library seems to be well maintained and regularly updated, so I'm ready to use it on projects of my own.

Credits

Photo by Stéphane Mingot on Unsplash