Apply Dependency Injection with Uber/Fx Golang
Lucian (Luận Nguyễn)

Lucian (Luận Nguyễn)

Nov 29, 2023

Apply Dependency Injection with Uber/Fx Golang

Introduction

Hi everyone, I'm Golang Developer currently. In this post, I will introduce to you how to apply uber-go/fx as dependency injection (DI) with Golang.

First at all, Let's find out what Dependency Injection is?

What is Dependency injection?

As you may be aware, there are various approaches to defining an object or structure with multiple dependencies, including:

type Router interface {
    Register(gGroup gin.IRouter)
}
type router struct {
    ctrl Controller
}

Great, Router can be created as follows:

The first way:

func NewRouter() Router {
    return &router{ctrl: NewController()}
}

The second way:

func NewRouter(ctrl Controller) Router {
    return &router{ctrl: ctrl}
}

Let's examine the block of code above. Both approaches are correct, but I would recommend using the second approach for creating an object. This approach makes it easier to write unit tests or integration tests for our code, and we will cover this topic in a future article.

In the second approach, the Controller is injected into the Router, and the Router does not concern itself with how the Controller is created. This is known as dependency injection.

Dependency Injection is passing a dependency to another object or structure, function. We do this as it allows the creation of dependencies outside the dependant object. This is useful as we can decouple dependency creation from the object being created.

According to uber-go/fx, It helps us:

  • Makes dependency injection easy.

  • Eliminates the need for global state and func init().

How to use uber-go/fx:

To gain a better understanding of the uber-go/fx framework, there are two key concepts that we need to grasp:

1. fx.Provide:

fx.Provide: provide an object or return error if we want to register with fx about some object.

Simple way providing an object as follows:

var Module = fx.Provide(provideGinEngine)
func provideGinEngine() *gin.Engine {
	return gin.Default()
}

Another approach is to return both the object and an error. If an error occurs, the framework will display it to us:

var Module = fx.Provide(provideGormDB)
func provideGormDB() (*gorm.DB, error) {
	uri := viper.GetString("MYSQL_URI")
	return gorm.Open(mysql.Open(uri))
}

We also return a struct with multiple objects with fx.Out and use it to inject with fx.In.

type UserParams struct {
    fx.Out
    dbRepo user.DBRepository
    userRepo user.Repository
}
func provideUserComponents(db *gorm.DB,cache cache.Cache) UserParams {
    dbRepo := user.NewDBRepository(db)
    userRepo := user.NewRepository(dbRepo, cache)
    return UserParams{
        dbRepo: dbRepo,
        userRepo: userRepo,
    }
}

Furthermore, uber-go/fx provides support for hooks such as onStart and onStop, which allow us to trigger and execute specific actions:

var Module = fx.Provide(provideMongoDBClient)
const defaultTimeout = 10 * time.Second
func provideMongoDBClient(lifecycle fx.Lifecycle) (*mongo.Client, error) {
	mongoDBURI := viper.GetString(env.MongoURI)
	client, err := db.GetDBConnection(mongoDBURI)
	if err != nil {
		return nil, err
	}
	lifecycle.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
			defer cancel()
			return client.Connect(ctx)
		},
		OnStop: func(ctx context.Context) error {
			return client.Disconnect(ctx)
		},
	})
	return client, nil
}
2. fx.Invoke:

fx.Invoke: registers functions that are executed eagerly on application start. Arguments for these invocations are built using the constructors registered by Provide

With fx.Invoke, we use it to initialize or execute a function, such as: init logger, register server, … Ex:

var Initialize = fx.Invoke(readConfig)
func readConfig() {
		replacer := strings.NewReplacer(".", "_")
		viper.SetEnvKeyReplacer(replacer)
		viper.AutomaticEnv()
		viper.SetConfigFile("config.yaml")
		viper.AddConfigPath(".")
		err := viper.ReadInConfig()
		if err != nil {
			panic(err)
		}
}

If we need to pass arguments, we can utilize the following syntax:

func Initialize(configFile, configPath string) fx.Option {
	return fx.Invoke(func() {
		viper.SetConfigFile(configFile)
		viper.AddConfigPath(configPath)
		viper.AddConfigPath(".")
		...
	})
}

Same as fx.Provide, we can use fx.lifecycle to add hooks for onStart/OnStop

func startServer(ginEngine *gin.Engine, lifecycle fx.Lifecycle) {
	port := viper.GetString("PORT")
	server := http.Server{
		Addr:    ":" + port,
		Handler: ginEngine,
	}
	ginEngine.GET("/ping", func(c *gin.Context) {
		c.String(http.StatusOK, "pong")
	})
	lifecycle.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			fmt.Println("run on port:", port)
			go func() {
				if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
					fmt.Errorf("failed to listen and serve from server: %v", err)
				}
			}()
			return nil
		},
		OnStop: func(ctx context.Context) error {
			return server.Shutdown(ctx)
		},
	})
}
3. Run with Fx:

Lastly, when initiating the service, we will employ the following approach:

...

func main() {
	app := fx.New(
		configfx.Initialize("config.yaml", "configs"),
		ginfx.Module,
		dbfx.Module,
		redisfx.Module,
		cachefx.Module,
		userfx.Module,
		serverfx.Module,
		fx.Invoke(
			registerService,
			startServer),
	)
	app.Run()
}
func registerService(ginEngine *gin.Engine, userSvcRouter usersvc.Router) {
	gGroup := ginEngine.Group("api/v1")
	userSvcRouter.Register(gGroup)
}
func startServer(ginEngine *gin.Engine, lifecycle fx.Lifecycle) {
	port := viper.GetString("PORT")
	// port := "8080"
	server := http.Server{
		Addr:    ":" + port,
		Handler: ginEngine,
	}
	ginEngine.GET("/ping", func(c *gin.Context) {
		c.String(http.StatusOK, "pong")
	})
	lifecycle.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			fmt.Println("run on port:", port)
			go func() {
				if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
					fmt.Errorf("failed to listen and serve from server: %v", err)
				}
			}()
			return nil
		},
		OnStop: func(ctx context.Context) error {
			return server.Shutdown(ctx)
		},
	})
}

---

Conclusion:

Applying Dependency Injection (DI) can greatly enhance the readability of code by providing a clear understanding of an object's dependencies. It also offers significant benefits when it comes to writing unit and integration tests for our code.

In the next article, we will delve into the topic of writing effective unit and integration tests specifically for Golang

Thanks for watching till here.

Lucian (Luận Nguyễn)

Lucian (Luận Nguyễn)

Software Engineer

Comments Form

Related Posts

Categories