How to build large Golang applications using FX

The Devops Guy
6 min readDec 27, 2022

--

Building complex Go applications can introduce a lot of coupling

Golang is a popular programming language with powerful features. However, one can find it complicated to organize a large codebase while juggling dependencies.

Go developers sometimes have to pass references of their dependencies to others. This leads to almost impossible to reuse code and tightly couples the technologies (like the database engine or the HTTP server) to the business code.

FX is a dependency injection tool created by Uber that can help you to avoid incorrect patterns, such as init functions in your packages and global variables to pass dependencies around. This, in total, helps to reuse your code.

This article will look at creating an example web application handling text snippets using FX to avoid tight coupling in your Golang code.

Organization

First, let's define how our code will be structured :

lib/
config/
db/
http/

config.yml
main.go

utils/

This organization splits the different actors of our application into their own Go package. For example, this will help if we need to replace the DB technology.

Each package will define the interfaces we expose to other packages and their implementations.

The main.go file will be the main injection point of our dependencies and will run our application.

Finally, the utils package will contain all the code snippets we will reuse in our application that do not rely on our dependencies.

To start, let’s write a basic main.go file :

package main

import "go.uber.org/fx"

func main() {
app := fx.New()
app.Run()
}

This declares the FX application and runs it. We will now see how to inject more features into this application.

Architecture of a module

To add functionality to our application, we will use FX modules. They allow creating of boundaries in your codebase, making your code more reusable.

Let’s start with the config module. It will contain the following files :

  • config.go will define the structure we will expose to our application.
  • fx.go will expose the module, set up everything we need, and load the configuration at startup.
  • load.go will be the implementation of our interface
// lib/config/config.go
package config

type httpConfig struct {
ListenAddress string
}

type dbConfig struct {
URL string
}

type Config struct {
HTTP httpConfig
DB dbConfig
}

This first file defines the structure of our configuration object.

// lib/config/load.go
package config

import (
"fmt"
"github.com/spf13/viper"
)

func getViper() *viper.Viper {
v := viper.New()
v.AddConfigPath(".")
v.SetConfigFile("config.yml")
return v
}

func NewConfig() (*Config, error) {
fmt.Println("Loading configuration")
v := getViper()
err := v.ReadInConfig()
if err != nil {
return nil, err
}
var config Config
err = v.Unmarshal(&config)
return &config, err
}

The load.go file uses the Viper framework to load the configuration from a YML file. I also added a sample print statement for later explanations.

// lib/config/fx.go
package config

import "go.uber.org/fx"

var Module = fx.Module("config", fx.Provide(NewConfig))

Here, we expose our FX module by using fx.Module. This function takes two types of arguments :

  • The first argument is the name of the module used for logging.
  • The rest arguments are the dependencies we want to expose to the application.

Here we only export the Config object by using fx.Provide . This function tells FX to use the NewConfig function to load the config.

It is worth noticing that NewConfig also returns an error if Viper fails to load the config. FX will display the error and exit if the error is not nil.

The second key takeaway is that the module does not export Viper but only the config instance. It allows you to replace Viper with any other configuration framework easily.

Loading our module

Now, to load our module, we only need to pass it to the fx.New function in main.go

// main.go
package main

import (
"fx-example/lib/config"
"go.uber.org/fx"
)

func main() {
app := fx.New(
config.Module,
)
app.Run()
}

Now when we run this code, we see in the log :

[Fx] PROVIDE    *config.Config <= fx-example/lib/config.NewConfig() from module "config"
...
[Fx] RUNNING

FX tells us that it successfully detected that fx-example/lib/config.NewConfig() provides our configuration, but we don’t see “Loading Configuration” in the console. This is because FX calls the providers only when required. We don’t use the config we just built here, so FX doesn’t load it.

We can temporarily add a single line to our fx.Newcall to check if everything works.

func main() {
app := fx.New(
config.Module,
fx.Invoke(func(cfg *config.Config) {}),
)
app.Run()
}

We added a call to fx.Invoke. This registers functions that we will be called eagerly at the start of the application. It will be the entry point of our program and will start our HTTP server later.

DB module

Let’s write the DB module using GORM, a Golang ORM.

package db

import (
"github.com/izanagi1995/fx-example/lib/config"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)

type Database interface {
GetTextByID(id int) (string, error)
StoreText(text string) (uint, error)
}

type textModel struct {
gorm.Model
Text string
}

type GormDatabase struct {
db *gorm.DB
}

func (g *GormDatabase) GetTextByID(id int) (string, error) {
var text textModel
err := g.db.First(&text, id).Error
if err != nil {
return "", err
}
return text.Text, nil
}

func (g *GormDatabase) StoreText(text string) (uint, error) {
model := textModel{Text: text}
err := g.db.Create(&model).Error
if err != nil {
return 0, err
}
return model.ID, nil
}

func NewDatabase(config *config.Config) (*GormDatabase, error) {
db, err := gorm.Open(sqlite.Open(config.DB.URL), &gorm.Config{})
if err != nil {
return nil, err
}
err = db.AutoMigrate(&textModel{})
if err != nil {
return nil, err
}
return &GormDatabase{db: db}, nil
}

In this file, we first declare an interface that allows storing text and retrieving it with its ID. We then implement that interface with GORM.

In the NewDatabase function, we have the configuration as an argument. FX will inject it automatically when we register our module.

// lib/db/fx.go
package db

import "go.uber.org/fx"

var Module = fx.Module("db",
fx.Provide(
fx.Annotate(
NewDatabase,
fx.As(new(Database)),
),
),
)

In the same way as the configuration module, we provide the NewDatabase function. But this time, we need to add an annotation.

This annotation tells that FX should not expose the result of the NewDatabase function as *GormDatabase but as the Database interface. This allows us once again to decouple the usage from the implementation, so we can replace Gorm later without having to change the code anywhere else.

Don’t forget to register the db.Module in main.go .

// main.go
package main

import (
"fx-example/lib/config"
"fx-example/lib/db"
"go.uber.org/fx"
)

func main() {
app := fx.New(
config.Module,
db.Module,
)
app.Run()
}

We now have a way to store our texts without thinking about the underlying implementation.

HTTP module

In the same way, we can build the HTTP module.

// lib/http/server.go
package http

import (
"fmt"
"github.com/izanagi1995/fx-example/lib/db"
"io/ioutil"
stdhttp "net/http"
"strconv"
"strings"
)

type Server struct {
database db.Database
}

func (s *Server) ServeHTTP(writer stdhttp.ResponseWriter, request *stdhttp.Request) {
if request.Method == "POST" {
bodyBytes, err := ioutil.ReadAll(request.Body)
if err != nil {
writer.WriteHeader(400)
_, _ = writer.Write([]byte("error while reading the body"))
return
}
id, err := s.database.StoreText(string(bodyBytes))
if err != nil {
writer.WriteHeader(500)
_, _ = writer.Write([]byte("error while storing the text"))
return
}
writer.WriteHeader(200)
writer.Write([]byte(strconv.Itoa(int(id))))
} else {
pathSplit := strings.Split(request.URL.Path, "/")
id, err := strconv.Atoi(pathSplit[1])
if err != nil {
writer.WriteHeader(400)
fmt.Println(err)
_, _ = writer.Write([]byte("error while reading ID from URL"))
return
}
text, err := s.database.GetTextByID(id)
if err != nil {
writer.WriteHeader(400)
fmt.Println(err)
_, _ = writer.Write([]byte("error while reading text from database"))
return
}
_, _ = writer.Write([]byte(text))
}
}

func NewServer(db db.Database) *Server {
return &Server{database: db}
}

This HTTP handler checks if the request is a POST or a GET request. If it is a POST request, it stores the body as text and sends the ID as the response. If it is a GET request, it fetches the text from the ID in the query path.

// lib/http/fx.go
package http

import (
"go.uber.org/fx"
"net/http"
)

var Module = fx.Module("http", fx.Provide(
fx.Annotate(
NewServer,
fx.As(new(http.Handler)),
),
))

Finally, we expose the Server as an http.Handler. This way, we can replace this simple HTTP server we just built with a more advanced tool like Gin or Gorilla Mux later.

We can now import the module into our main function and write an Invoke call to start the server.

// main.go
package main

import (
"fx-example/lib/config"
"fx-example/lib/db"
"fx-example/lib/http"
"go.uber.org/fx"
stdhttp "net/http"
)

func main() {
app := fx.New(
config.Module,
db.Module,
http.Module,
fx.Invoke(func(cfg *config.Config, handler stdhttp.Handler) error {
go stdhttp.ListenAndServe(cfg.HTTP.ListenAddress, handler)
return nil
}),
)
app.Run()
}

And voila! We have a simple HTTP server connected to an SQLite database, all using FX.

To summarize, we learned today that FX helps us decouple our code to make it more reusable and less reliant on the undergoing implementation. It also helps to better understand the overall architecture without navigating a complicated chain of invocations and references.

If you enjoyed this article, please consider leaving a reaction. Any comments are welcome below. This article took a long time to prepare, so if you want to support me, you can subscribe to Medium membership here. This also gives you unlimited access to Medium.

--

--

The Devops Guy

Devops and developer || AWS, Terraform, Saltstack and more || Golang, Python, JS || Twitter @a_devops_guy