How to build large Golang applications using FX
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.New
call 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.