github.com/floatdrop/di

Dependency injection for Go, built on generic methods.

Register services, resolve them with s.Get[T](), and let the container build, start, stop and check the graph. No code generation, no dependencies.

go get github.com/floatdrop/di

This page is one application, read top to bottom: a small HTTP service with a database, a cache in front of it, a mailer running in the background, and a handler built per request. Every code block is a file from examples/guide in the repository. The Go toolchain compiles and tests them on every change, so what you see is what runs.

1The shape of an application

Each package owns its services and exposes one function, Module, that registers them. Nothing else about a package is special: constructors are plain functions, types are plain types, and the only file that knows the whole graph is main.

examples/guide
cmd/api/main.go       composes the modules, checks the graph, runs
internal/config/      settings, registered as a value
internal/storage/     the database, and the store built on it
internal/cache/       a cache wrapped around the store
internal/mail/        a background worker
internal/api/         the HTTP server and its handlers

2Constructors and a module

newDB and newPGStore take what they need as parameters and return what they make; newDB can fail. Neither imports the container. Nothing in this application needs the general form, Provide, which takes a closure over the scope; the two mix freely when something does.

Module hands them over with Wire. The type argument is the key the service is served under: *db for the connection, and the Store interface for the store, since a *pgStore is assignable to it. The parameters of a wired constructor are its dependencies, which is how the container knows the graph before anything is built. Hooks are typed on the value they receive and run when the application starts and stops.

Privacy is Go's. Keys are types, so *db, which only this package can name, is a service only this package can resolve. The package exports its contract, Store and User, and its Module; the connection has a lifecycle the container runs and is otherwise nobody else's business.

internal/storage/storage.go
// Package storage owns the database connection and the store built on it.
// It exports its contract, Store and User, and its Module; the connection
// and the implementation are private. Keys are types, so a type only this
// package can name is a service only this package can resolve.
package storage

import (
	"context"
	"errors"

	"github.com/floatdrop/di"
	"github.com/floatdrop/di/examples/guide/internal/config"
)

// db is the database connection: private to the package, with a lifecycle
// the container runs. Its constructor takes what it needs as parameters and
// knows nothing about the container.
type db struct{ dsn string }

func newDB(cfg config.Config) (*db, error) {
	if cfg.DSN == "" {
		return nil, errors.New("storage: DSN is empty")
	}
	return &db{dsn: cfg.DSN}, nil
}

func (db *db) Ping(context.Context) error { return nil }
func (db *db) Close() error               { return nil }

// Store is what the rest of the application depends on. Handlers take the
// interface; the container serves whatever is registered for it.
type Store interface {
	Find(ctx context.Context, id string) (User, error)
	Ping(ctx context.Context) error
}

type User struct{ ID, Name string }

type pgStore struct{ db *db }

func newPGStore(db *db) *pgStore { return &pgStore{db: db} }

func (s *pgStore) Find(_ context.Context, id string) (User, error) {
	return User{ID: id, Name: "user " + id + " via " + s.db.dsn}, nil
}

func (s *pgStore) Ping(ctx context.Context) error { return s.db.Ping(ctx) }

// Module registers the package's services. Constructors are handed over as
// they are; the hooks are typed on what they receive. The Store key is
// served by the private constructor, whose result is assignable to it.
func Module(s *di.Scope) {
	s.Wire[*db](newDB).
		OnStart(func(ctx context.Context, db *db) error { return db.Ping(ctx) }).
		OnStop(func(_ context.Context, db *db) error { return db.Close() })
	s.Wire[Store](newPGStore)
}

3Configuration is a value

A value you already have is registered with Value. It is a key like any other: NewDB receives it as a parameter, and a test replaces it with Override() and every service downstream follows.

internal/config/config.go
// Package config reads the settings the application starts with.
package config

import (
	"os"

	"github.com/floatdrop/di"
)

type Config struct {
	Addr string
	DSN  string
}

func Load() Config {
	return Config{
		Addr: env("ADDR", ":8080"),
		DSN:  env("DSN", "postgres://localhost/app"),
	}
}

func env(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

// Module registers the configuration as a value. A test overrides it with
// s.Value(config.Config{...}).Override() and everything downstream follows.
func Module(s *di.Scope) { s.Value(Load()) }

4Composition in main

Use applies the modules in order and attributes each registration to the module that made it, which is what an error names when two modules collide. A second registration of a key without Override() is rejected, naming both, so one module cannot rewire another unnoticed.

Validate walks the declared graph without building anything, told what a request scope will hold. A dependency nothing provides, a cycle, or a request-scoped service captured by a singleton fails here, at startup, rather than on the first request. Then Run starts the eager services, waits for a signal, and stops everything in reverse order within the timeout.

cmd/api/main.go
// Command api is the application: it composes the modules, checks the graph,
// and runs until a signal arrives.
package main

import (
	"context"
	"log"
	"net/http"
	"time"

	"github.com/floatdrop/di"
	"github.com/floatdrop/di/dihttp"
	"github.com/floatdrop/di/examples/guide/internal/api"
	"github.com/floatdrop/di/examples/guide/internal/cache"
	"github.com/floatdrop/di/examples/guide/internal/config"
	"github.com/floatdrop/di/examples/guide/internal/mail"
	"github.com/floatdrop/di/examples/guide/internal/storage"
)

func main() {
	app := di.New()
	app.Use(config.Module, storage.Module, cache.Module, mail.Module, dihttp.Module, api.Module)

	// Nothing has been built yet. The constructors declared their
	// dependencies, so the graph is checked here, as a request scope holding
	// an *http.Request would resolve it.
	if err := app.Validate(di.Provided[*http.Request]()).Err(); err != nil {
		log.Fatal(err)
	}

	// Run starts the eager services and their hooks, waits for SIGINT or
	// SIGTERM, then stops everything in reverse order within the timeout.
	if err := app.Run(context.Background(), di.StopTimeout(10*time.Second)); err != nil {
		log.Fatal(err)
	}
}

5Background workers

A Worker runs for as long as its service does: started in its own goroutine when the service starts, cancelled by Stop, and waited for before anything it depends on is torn down. Returning an error from it stops the application. Eager says the mailer exists by the time Start returns rather than on first use.

internal/mail/mail.go
// Package mail sends messages from a background worker.
package mail

import (
	"context"
	"log"

	"github.com/floatdrop/di"
)

type Mailer struct{ queue chan string }

func New() *Mailer { return &Mailer{queue: make(chan string, 64)} }

// Send queues a message; the worker delivers it.
func (m *Mailer) Send(msg string) {
	select {
	case m.queue <- msg:
	default:
		log.Println("mail: queue full, dropped", msg)
	}
}

// Run delivers until ctx is cancelled, which Stop does before the services
// the mailer depends on are stopped.
func (m *Mailer) Run(ctx context.Context) error {
	for {
		select {
		case msg := <-m.queue:
			log.Println("mail: sent", msg)
		case <-ctx.Done():
			return nil
		}
	}
}

// Module registers the mailer as an eager service with a worker: it exists
// once Start returns, its loop runs in its own goroutine, and Stop cancels
// the loop and waits for it.
func Module(s *di.Scope) {
	s.Wire[*Mailer](New).
		Eager().
		Worker(func(ctx context.Context, m *Mailer) error { return m.Run(ctx) })
}

6HTTP and request scopes

A dihttp.Middleware opens a child scope for each request, holding the *http.Request. Services declared Scoped in the application scope are built once per request scope, from singletons and request-scoped values alike, and stopped with it. A handler reaches its scope through di.FromContext, or through dihttp.Handle, which does that for a handler type's method.

A handler type covers one resource, with a method per route, so its dependencies are declared once. dihttp.Handle((*Users).Show) resolves the type from the request scope and calls the method; a method expression names both, so no type argument is needed. Users is Scoped because it needs the caller; Health needs nothing from the request and is an ordinary singleton, and Handle follows either lifetime.

The middleware needs the scope itself, to open a child per request, so dihttp.Module registers it as a service and the server takes it as a parameter like anything else. OnDrain runs before anything is stopped, so requests in flight keep their scopes while http.Server.Shutdown waits for them.

internal/api/api.go
// Package api serves HTTP. The server is a singleton with a lifecycle. A
// handler type covers one resource, with a method per route; it is Scoped
// when it needs the request, and built in the request scope the dihttp
// middleware opens, or a plain singleton when it does not.
package api

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"

	"github.com/floatdrop/di"
	"github.com/floatdrop/di/dihttp"
	"github.com/floatdrop/di/examples/guide/internal/config"
	"github.com/floatdrop/di/examples/guide/internal/mail"
	"github.com/floatdrop/di/examples/guide/internal/storage"
)

// Caller is who is making the request. It depends on the *http.Request, which
// only a request scope provides.
type Caller struct{ Name string }

func NewCaller(r *http.Request) *Caller { return &Caller{Name: r.Header.Get("X-User")} }

// Users is built once per request, from singletons and request-scoped values
// alike, and serves every route about users.
type Users struct {
	store  storage.Store
	mail   *mail.Mailer
	caller *Caller
}

func NewUsers(store storage.Store, m *mail.Mailer, caller *Caller) *Users {
	return &Users{store: store, mail: m, caller: caller}
}

func (u *Users) Show(w http.ResponseWriter, r *http.Request) {
	user, err := u.store.Find(r.Context(), r.PathValue("id"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusNotFound)
		return
	}
	fmt.Fprintln(w, user.Name)
}

func (u *Users) Greet(w http.ResponseWriter, r *http.Request) {
	user, err := u.store.Find(r.Context(), r.PathValue("id"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusNotFound)
		return
	}
	u.mail.Send(u.caller.Name + " greets " + user.Name)
	w.WriteHeader(http.StatusAccepted)
}

// Health needs nothing from the request, so it is an ordinary singleton. It
// asks the store, which is the storage package's contract; the connection
// behind it is that package's own business.
type Health struct{ store storage.Store }

func NewHealth(store storage.Store) *Health { return &Health{store: store} }

func (h *Health) Check(w http.ResponseWriter, r *http.Request) {
	if err := h.store.Ping(r.Context()); err != nil {
		http.Error(w, err.Error(), http.StatusServiceUnavailable)
		return
	}
	fmt.Fprintln(w, "ok")
}

// NewServer builds the routes. Each one resolves its handler from the
// request scope the middleware opens; dihttp.Module provides the middleware.
func NewServer(cfg config.Config, mw dihttp.Middleware) *http.Server {
	mux := http.NewServeMux()
	mux.Handle("GET /users/{id}", dihttp.Handle((*Users).Show))
	mux.Handle("POST /users/{id}/greet", dihttp.Handle((*Users).Greet))
	mux.Handle("GET /healthz", dihttp.Handle((*Health).Check))
	return &http.Server{Addr: cfg.Addr, Handler: mw(mux)}
}

// Module registers the request-scoped values, the handlers and the server,
// with the hooks that bind, drain and close it.
func Module(s *di.Scope) {
	s.Wire[*Caller](NewCaller).Scoped()
	s.Wire[*Users](NewUsers).Scoped()
	s.Wire[*Health](NewHealth)
	s.Wire[*http.Server](NewServer).
		Eager().
		OnStart(func(_ context.Context, srv *http.Server) error {
			// Bind synchronously, so a busy port fails Start; serve in the
			// background, and take the application down if serving stops.
			ln, err := net.Listen("tcp", srv.Addr)
			if err != nil {
				return err
			}
			go func() {
				if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
					s.Shutdown(err)
				}
			}()
			return nil
		}).
		// Draining runs before anything is stopped, so requests still in
		// flight keep their scopes and everything those depend on.
		OnDrain(func(ctx context.Context, srv *http.Server) error { return srv.Shutdown(ctx) }).
		OnStop(func(_ context.Context, srv *http.Server) error { return srv.Close() })
}

7Wrapping without replacing

Wrap composes over whatever serves a key. The wrapper takes that value first and its other dependencies after it. The store keeps its registration and its hooks, is built first, and is stopped after the wrapper, and the wrapper forwards what it does not change. The one thing to get right is module order: the cache's module comes after storage's. A wrapper registered in a child scope applies to that scope and its descendants only. Like storage, this package exports only its Module: a cross-cutting concern composes over an exported contract, never over a package's internals.

internal/cache/cache.go
// Package cache puts a cache in front of the store. It replaces nothing:
// the store keeps its registration and hooks, and this wraps it. The package
// exports only its Module.
package cache

import (
	"context"
	"sync"

	"github.com/floatdrop/di"
	"github.com/floatdrop/di/examples/guide/internal/storage"
)

type cache struct {
	mu    sync.Mutex
	users map[string]storage.User
	hits  int
}

func newCache() *cache { return &cache{users: map[string]storage.User{}} }

// cachingStore is a Store that asks the one it wraps only on a miss, and
// forwards what it does not change.
type cachingStore struct {
	next  storage.Store
	cache *cache
}

// newCachingStore takes the store it wraps first, then its dependencies.
func newCachingStore(next storage.Store, c *cache) storage.Store {
	return &cachingStore{next: next, cache: c}
}

func (s *cachingStore) Find(ctx context.Context, id string) (storage.User, error) {
	s.cache.mu.Lock()
	user, ok := s.cache.users[id]
	if ok {
		s.cache.hits++
	}
	s.cache.mu.Unlock()
	if ok {
		return user, nil
	}
	user, err := s.next.Find(ctx, id)
	if err != nil {
		return user, err
	}
	s.cache.mu.Lock()
	s.cache.users[id] = user
	s.cache.mu.Unlock()
	return user, nil
}

func (s *cachingStore) Ping(ctx context.Context) error { return s.next.Ping(ctx) }

// Module registers the cache and wraps whatever serves Store by now. Order
// matters: this module comes after storage's.
func Module(s *di.Scope) {
	s.Wire[*cache](newCache)
	s.Wrap[storage.Store](newCachingStore)
}

8Testing by overriding

di.Test wires the modules into a fresh scope and stops it when the test ends. Overriding the configuration is enough to point the store at another database; a fake would be s.Value(&fake).Override() just the same. The marker is required: a second registration without it is rejected, so a test cannot pass against production wiring by accident.

internal/storage/storage_test.go
package storage_test

import (
	"context"
	"strings"
	"testing"

	"github.com/floatdrop/di"
	"github.com/floatdrop/di/examples/guide/internal/config"
	"github.com/floatdrop/di/examples/guide/internal/storage"
)

// The production modules, with the configuration overridden: the store is
// built against a database that dials the test DSN, and nothing else in the
// wiring changes.
func TestStoreFindsUsers(t *testing.T) {
	s := di.Test(t, config.Module, storage.Module)
	s.Value(config.Config{DSN: "sqlite://memory"}).Override()

	user, err := s.Get[storage.Store]().Find(context.Background(), "42")
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(user.Name, "sqlite://memory") {
		t.Fatalf("store was built against the wrong database: %q", user.Name)
	}
}

The wrapper is tested through the same modules, with nothing faked: two lookups, one hit. It is an internal test, because only the cache package can name its own cache.

internal/cache/cache_test.go
package cache

import (
	"context"
	"testing"

	"github.com/floatdrop/di"
	"github.com/floatdrop/di/examples/guide/internal/config"
	"github.com/floatdrop/di/examples/guide/internal/storage"
)

// An internal test, since the cache is private: only this package can name
// *cache, so only this package can read its hits.
func TestSecondLookupIsAHit(t *testing.T) {
	s := di.Test(t, config.Module, storage.Module, Module)
	store := s.Get[storage.Store]()
	for range 2 {
		if _, err := store.Find(context.Background(), "42"); err != nil {
			t.Fatal(err)
		}
	}
	if hits := s.Get[*cache]().hits; hits != 1 {
		t.Fatalf("want one hit, got %d", hits)
	}
}

9Seeing the graph

Before anything is built, Explain draws what the wired constructors declared: dashed edges, the wrapper over the store, and which service declares the one you asked about. After a build it draws what actually happened, solid, followed by what needed it. Graph renders the whole application as Graphviz DOT. This is app.Explain[storage.Store]() at startup, pinned by a test in the repository.

app.Explain[storage.Store]()
storage.Store: singleton wrapper in root, not built (provided at internal/cache/cache.go:60)
├╌╌ storage.Store: singleton in root, not built (provided at internal/storage/storage.go:56)
│   └╌╌ *storage.db: singleton in root, not built (provided at internal/storage/storage.go:53)
│       └╌╌ config.Config: value in root, not built (provided at internal/config/config.go:31)
└╌╌ *cache.cache: singleton in root, not built (provided at internal/cache/cache.go:59)
declared by: *api.Users in root, *api.Health in root

Modules is the same information read by module rather than by service: what each provides, what it needs and who serves it, what it wraps, and which of its constructors are closures. It is derived from the registrations, so there is no manifest to keep in step. This is the whole application, before anything is built, pinned by a test as well.

app.Modules()
config.Module
  provides   config.Config
storage.Module
  provides   *storage.db, storage.Store
  needs      config.Config ← config.Module
cache.Module
  provides   *cache.cache
  wraps      storage.Store ← storage.Module
mail.Module
  provides   *mail.Mailer
dihttp.Module
  provides   dihttp.Middleware
  unchecked  dihttp.Middleware (closures: needs known when they run)
api.Module
  provides   *api.Caller, *api.Users, *api.Health, *http.Server
  needs      *http.Request ← owed to a resolving scope
             storage.Store ← cache.Module
             *mail.Mailer ← mail.Module
             config.Config ← config.Module
             dihttp.Middleware ← dihttp.Module

10Run it

Ctrl-C drains the server, cancels the mailer, closes the database, in that order, and reports any hook that failed.

shell
git clone https://github.com/floatdrop/di && cd di
go run ./examples/guide/cmd/api &
curl -H 'X-User: ada' localhost:8080/users/42

The README covers the rest: groups, observers, and the rules the container enforces. How it works goes the other way, from Get to a value: lifetimes, phases, cycles and shutdown, with diagrams.