Using a Resolver To Keep the Main File Clean

Today I am going to show you how I use a resolver to resolve dependencies and keep my main.go file clean.

Today I am going to show you how I use a resolver to resolve dependencies and keep my main.go file clean.

PS I am well aware that the image to this blog post is a revolver, Images of “resolvers” are rather lacking and who doesn’t like Westerns.

What is a resolver?

A resolver is a struct that I use to help me to initialise and resolve services and dependencies. Think DB, Redis connections or a logger.

The Resolver ensure that there is only ever one instance of a service and moves all the setup logic into into its own package.

The Problem

When I first began working with Go in a professional capacity, I was writing AWS Lambdas. All of these required a logger, and some required a DB connection. Naturally I began writing the setup logic for these components in the main.go.

package main
import (
	"database/sql"
	"fmt"
	"github.com/sirupsen/logrus"
	"gopkg.in/birkirb/loggers.v1"
	"os"
	mapper "github.com/birkirb/loggers-mapper-logrus"
)
func main()  {
	dbConf := GetDBConfig()
	db, err := sql.Open("mysql",fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", dbConf.dbUser, dbConf.dbPass, dbConf.dbHost, dbConf.dbPort, dbConf.dbName) )
	if err != nil {
		panic(err)
	}

	db.SetMaxIdleConns(5)
	db.SetMaxOpenConns(5)

	// init the repo with the help of the resolver
	repo := Repo{
		DB: db,
	}

	// init the app
	a := App{
		Logger: GetLogger(),
		Repo: repo,
	}
	a.Run()
}
type Config struct {
	dbUser string
	dbPass string
	dbHost string
	dbPort string
	dbName string
}
func GetDBConfig() Config {
	return Config{
		dbUser: getMandidatoryEnvVar("DB_USER"),
		dbPass: getMandidatoryEnvVar("DB_PASS"),
		dbHost: getMandidatoryEnvVar("DB_HOST"),
		dbPort: getMandidatoryEnvVar("DB_PORT"),
		dbName: getMandidatoryEnvVar("DB_NAME"),
	}
}
func getMandidatoryEnvVar(key string) string {
	val := os.Getenv(key)
	if val == "" {
		panic(fmt.Sprintf("mandidatory env var not set %s", key))
	}
	return val
}
func GetLogger () loggers.Contextual {
	l := logrus.New()
	l.Out = os.Stdout
	l.Level = logrus.InfoLevel
	l.SetFormatter(&logrus.JSONFormatter{})
	return mapper.NewLogger(l)
}

Since the main.go file is the first file which another developer of your application will come to let us not present them with a wall of text.

The Solution

I created a director structure that looked like;

  • project_name
    • internal
      • db
        • config.go // Grab env vars for conn + error checking
      • config.go // the main conf struct that will plug into resolver
      • resolver.go
    • main.go

Tip: anything put within the /internal dir will not be exported outside of the project. It is for use of this project only

The Files

First up internal/db/config.go.

This file contains everything we need to instantiate a DB connection. This file fetches Environment Variables and can format a connection string. One less thing for our main.go file to contain.

package db

import (
	"fmt"
	"os"
)
type Config struct {
	dbUser string
	dbPass string
	dbHost string
	dbPort string
	dbName string
}
func GetConfig() Config {
	return Config{
		dbUser: getMandidatoryEnvVar("DB_USER"),
		dbPass: getMandidatoryEnvVar("DB_PASS"),
		dbHost: getMandidatoryEnvVar("DB_HOST"),
		dbPort: getMandidatoryEnvVar("DB_PORT"),
		dbName: getMandidatoryEnvVar("DB_NAME"),
	}
}
func (c Config) GetConnectionString() string {
	return fmt.Sprintf(
		"%s:%s@tcp(%s:%s)/%s?parseTime=true", c.dbUser, c.dbPass, c.dbHost, c.dbPort, c.dbName)
}
func getMandidatoryEnvVar(key string) string {
	val := os.Getenv(key)
	if val == "" {
		panic(fmt.Sprintf("mandidatory env var not set %s", key))
	}
	return val
}

Second internal/config.go

For some projects I also resolve a Redis connection this way, that would mean that I also need an internal/Redis.config.go. This file is used to tie all the configs together, and it is this that is fetched in the main.go file and injected straight into the resolver struct.

package internal

import "no_vcs/me/resolver/internal/db"

// Config ties together all other application configuration types.
type Config struct {
	DB db.Config
}
func NewConfiguration() *Config {
	return &Config{
		DB: db.GetConfig(),
	}
}

Finally, the internal/resolver.go

package internal

import (
	"database/sql"
	mapper "github.com/birkirb/loggers-mapper-logrus"
	"gopkg.in/birkirb/loggers.v1"
	_ "github.com/go-sql-driver/mysql"
	"github.com/sirupsen/logrus"
	"os"
)

type Resolver struct {
	config *Config
	logger loggers.Contextual
	db *sql.DB
}
func NewResolver(c *Config) *Resolver {
	return &Resolver{
		config: c,
	}
}
type LoggerResolver interface {
	ResolveLogger() loggers.Contextual
}
func (r *Resolver) ResolveLogger() loggers.Contextual {
	if r.logger == nil {
		l := logrus.New()
		l.Out = os.Stdout
		l.Level = logrus.InfoLevel
		l.SetFormatter(&logrus.JSONFormatter{})
		r.logger = mapper.NewLogger(l)
	}
	return r.logger
}
func (r *Resolver) ResolveDB() *sql.DB {
	if r.db == nil {
		db, err := sql.Open("mysql", r.config.DB.GetConnectionString())
		if err != nil {
			panic(err)
		}

		// find a place for these
		db.SetMaxIdleConns(5)
		db.SetMaxOpenConns(5)

		return db
	}
	return r.db
}

The main thing to note here is that. The resolver only ever run the initialisation code once, and the result will be a pointer that is assigned to a field in the resolver struct.

The Finished Product

A main.go file which is intuitive and easy to read.

package main

import (
	"database/sql"
	"gopkg.in/birkirb/loggers.v1"
	"no_vcs/me/resolver/internal"
)

func main()  {
	// init the environment configuration
	c := internal.NewConfiguration()
	// init the resolver
	r := internal.NewResolver(c)


	// init the repo with the help of the resolver
	repo := Repo{
		DB: r.ResolveDB(),
	}

	// init the app
	a := App{
		Logger: r.ResolveLogger(),
		Repo: repo,
	}
	a.Run()
}

type Repo struct {
	DB *sql.DB
}

func (r Repo) CheckConnection() error {
	return r.DB.Ping()
}

type App struct {
	Logger loggers.Contextual
	Repo Repo
}

func (a App) Run () {
	a.Logger.Info("running something")
	// ...
	if err := a.Repo.CheckConnection(); err != nil {
		panic(err)
	}
	//
	a.Logger.Info("operation complete")
}

If you would like to dig further into this pattern, please checkout the codebase here, if you like don’t forget to star :)