cyx

When I mention dependency injection to folks who have Java or .NET backgrounds it usually invokes a few sets of questions — to which my usual response is:

It's likely not as sophisticated as what you're thinking — and it doesn't have to be!

While there are methods to use more sophisticated approaches in Go, starting off projects the simplest way possible is still the preferred approach.

Definitions

Dependency injection represents the D in SOLID principles. Quoting from wikipedia:

In object-oriented computer programming, SOLID is a mnemonic acronym for five design principles intended to make software designs more understandable, flexible, and maintainable.

Why dependency injection is important in practice?

Let's use a hypothetical application which is somewhat like a lambda: essentially, we allow customers to store their code, and we can run them in some sort of containerized environment. In this product, our ubiquitious language might have the following entities:

  • Code – represents a customer's arbitrary code.

In addition, we might require the following service abstractions:

  • Repository – provides an abstraction of data.
  • Runtime – allows you to execute arbitrary code.

And a classical router, controller, or command layer to receive requests and act on them:

  • Server – can be considered as the Command layer as in CQRS.

Sketching a minimal example of this:

package api

type Server struct {
        Repo *Repository
        Runtime *Runtime
}

func (s *Server) ExecuteCode(ctx context.Context, req *ExecuteRequest) (*ExecuteResponse, error) {
        code, err := s.Repo.FetchCode(ctx, req.ID)
        if err != nil {
                return nil, err
        }

        res, err := s.Runtime.Execute(ctx, code)
        if err != nil {
                return nil, err
        }

        return &ExecuteResponse{Payload: res.Payload}
}

Playing out what tech we'll use

If we stick to boring technology:

  • Repository – will likely just be backed by postgres.
  • Runtime – we can explore using lambda (or openwhisk, cloudflare workers, the list goes on).

How will we test these?

If we want to have a good testing experience, we'll strive for the following:

# just works without any dependencies at all.
go test ./...

# does integration testing using env vars or flags.
env $(cat .env) go test /... -tags integration

So what does this mean if we want to be able to test without any postgres dependency? Likely it means we either don't test the postgres implementation of the Repository at all. That's probably fine, but then would we also skip testing the Server layer because it depends on *Repository?

Similarly, if our Runtime implementation relies on lambda, how will we do testing with that?

This is where interfaces, and good design helps!

It's all about the interface

If we changed the code above to instead rely on an interface:

package api

type Repository interface {
        FetchCode(ctx context.Context, id string) (Code, error)
}

type Runtime interface {
        Execute(ctx context.Context, Code) (RuntimeResponse, error)
}

type Server struct {
        Repo Repository
        Runtime Runtime
}

Then we can at least test the Server layer by providing a mock implementation for our dependency-less testing.

We can then do -tags integration style testing in CI to provide a real database, and a test AWS environment for us to interact with lambda so we're guaranteed that any change we do hits the real thing all the time — but we still empower developers with a faster feedback loop on average.

So what about dependency injection?

Ultimately, because we've designed our system to take in well defined services instead of concrete implementations, we can simply change how we initialize our main file:

func main() {
        var cfg config
        if err := envdecode.StrictDecode(&cfg); err != nil {
                log.Fatal(err)
        }

        ctx := contextWithSignals(os.Interrupt)
       if err := run(ctx, cfg); err != nil {
                log.Fatal(err)
        }
}

func run(ctx context.Context, cfg config) error {
        db, err := sql.Open(cfg.DatabaseURL)
        if err != nil {
                return err
        }

        repository, err := postgres.NewRepository(db)
        if err != nil {
                return err
        }

        runtime, err := lambda.NewRuntime(cfg.AWS.Key, cfg.AWS.Secret)
        if err != nil {
                return err
        }

        server := &api.Server{
                Repository: repository,
                Runtime: runtime,
        }

        return server.Run()
}

And then in our test, we can imagine providing all the mock / stub implementations as necessary.

Will we end up writing a lot of boilerplate mock code?

Probably yes — and it really depends how much of your domain requires very heavy dependencies.

If your domain is really just interacting with a database — maybe you can argue that in order for your devs to work with the code, a local postgres must be available (or one in docker) — and that's fine! Ultimately it's all about tradeoffs.

Once the set of dependencies become a bit too heavy though, we'd want to keep the developer feedback loop as fast as possible, while answering the following questions:

  1. How much of the project business logic involves these heavy dependencies? Is it 10%? 50%?
  2. How often do developers modify these external heavy dependencies? (In the example we have, how much of the work becomes modifying lambda code, vs modifying postgres code, vs modifying the server code?)

As is often the case with software engineering, it's all about tradeoffs — and when you need to reach for dependency injection it's just right there in your toolbelt.

Footnotes

  • In certain cases, certain companies have even gone and implemented an entire mock implementation for their customers to use. LetsEncrypt shipped pebble which is a miniaturized version of their real service offering.
  • When should you reach for sophisticated tools like wire? I personally would never use those tool — but YMMV again.