Avoid singletons when possible!

"Clear is better than Clever". Does this statement mean a lot to you as a software engineer? Everybody loves maintainable code, right! Avoiding singletons makes code cleaner and removing singletons removes the clever magic from code. So, what's a singleton anyway?

package boot

var Database *db.Db

func InitDatabase(ctx context.Context, config db.Config) error {
    Database = db.NewDb(config)

    return nil
}

The global variable Database here is used as a singleton.

So you started on a new codebase and planned to write a test case to use some functionality. The next thing you did was check the definition of a function.

func NewRocket(name string) *Rocket {}

func (r *Rocket) Launch() error {}

Great, the function takes a string and gives back an instance of Rocket, and the Launch() will launch it and return an error! So you went about writing a test.

func TestRocketLaunch(t *testing.T) {
    rocket := NewRocket(name)
    err := rocket.Launch()
    if err != nil {
        t.Error(err)
    }
}

When the test runs, it fails with a nil pointer exception. What happened? On checking the code inside Rocket, or taking help from some engineer in your team! You understand the "database" had to be initialized. The function input parameters did not say anything about the database dependency the function had, it assumes db connection is initialized as a global variable, as a singleton. This is magic! Do you like magic in code?

import "internal/boot"

func NewRocket(name string) *Rocket {
    boot.database.Something()

    // ...
}

I have seen this boot package grow really big with many singleton initializations: InitWorker, InitKafkaProducer, InitMigration, InitCache, InitTest, and InitWhatNot, etc... Then you have to worry about the order of initialization, handle initialize var when not initialized, thread safety, memory leak prevention, etc... Also, every other package is importing this boot package... Why? keep things where they belong, in their own packages! Keep a clean dependency tree! Objects when passed as inputs and not imported as a global variable can be easily mocked also.

Long story short, the code becomes unmaintainable and difficult to understand with function behavior being secretly altered by a globally initialized variable. Einstein did not believe in spookiness and recently it was proved that universe is spooky indeed! But spookiness in code should be avoided whenever possible.

So, if we do not have singletons, what do we do? Every object defines its dependenciesas input, no global imports. There are three major concerns I heard whenever I recommended this:

  1. Object needs to be passed in nested code layers.
  2. Input parameters to my objects are becoming huge.
  3. Loggers, Tracers, and Metrics are needed in every single place.

Let's focus on solving the first two.

If you have structured the code in proper layers, every layer object can be constructed in layers and you do not need to pass the same object in nested layers. The database dependency is only at the repo layer in the below example!

package main

func main() {
    // ...
    cfg := config.New(env)
    db := storage.New(cfg.Database)

    userRepo := user.NewRepo(ctx, db)
    userService := user.NewService(ctx, userRepo)
    userServer := user.NewServer(userService)
}

The problem of object input parameters becoming huge can be solved using creational design patterns: factory pattern, builder pattern, and option pattern.

There are enough articles to learn about these patterns, so I added only few snippets of what the complex object construction looks like with these patterns:

Builder Pattern

func main() {
    // ...
    builder := New()
    car := builder.TopSpeed(50)
                  .Paint("blue")
                  .Build()
}

Option Pattern

func main() {
    // ...
    svr := server.New(
        server.WithHost("localhost"),
        server.WithPort(8080),
        server.WithTimeout(time.Minute),
        server.WithMaxConn(120),
    )
}

This brings us to our last concern: logger, metrics, and tracers. The main purpose of writing this blog was to bring up the importance of avoiding singletons whenever possible. Loggers, metrics and tracers are exceptions to this rule. Do note: application configuration is not part of this exception list. Configuration should be broken down into small parts and passed, for example: config.Database should be passed when creating database object.

package config

import (
    "github.com/alok87/rocket/internal/storage"
    "github.com/alok87/rocket/internal/server"
    "github.com/alok87/rocket/internal/auth"
)

type Config struct {
    // Note: database configurations are in 
    // database package and not here
    Database  storage.Database
    Server    server.Config
    Auth      auth.Config
}

I have seen few avoid logger, tracing and metrics as singletons by treating them as any other dependencies and few avoid them by passing them in request contexts. We should aim to avoid it as the default rule, the default should not be inverse.