Dependency injection in Go with uber-go/fx
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 acontext.Context
interface, - We then define the constructor functions of our different objects
config
,log
andhttp
, - Then, when the application is executed (here via the
Run
method), thehttp.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