Build-time dependency injection for Go

Wiring you can read at 3am.

Servo reads your constructors, resolves the object graph before you build, and writes out plain Go that constructs, starts, supervises and shuts down your application in dependency order.

no reflection · no runtime registry · no init() · no hand-written wiring

go install github.com/okian/servo/v3/cmd/servo@latest

The whole idea

Same project either way. Only main changes.

The project below is ordinary Go. Nothing imports servo, and a component joins the lifecycle by having a method rather than registering for one — so it is identical whether or not you use servo. What differs is the file that wires it together: construction in dependency order, rollback when one fails, every Run supervised, shutdown in reverse.

With servo that file is generated, from a build-tagged spec naming only the roots — servo.Root[*api.Server]() — which never reaches your binary. Everything else is read off the constructor signatures.

The project4 packages · 0 import servo
// config/config.go — reads the environment once;
// every package parses its own slice of it off this
func NewEnv() *Env

// logger/logger.go
type Config struct {
    Level string `env:"LEVEL" envDefault:"info"`
}
func NewConfig(config.Source) (*Config, error)
func New(cfg *Config) *Logger
func (l *Logger) Stop(context.Context) error

// postgres/postgres.go
type Config struct {
    DSN string `env:"DSN,required"`
}
func NewConfig(config.Source) (*Config, error)
func New(cfg *Config, log *logger.Logger) (*DB, error)
func (d *DB) Init(context.Context) error
func (d *DB) Stop(context.Context) error

// api/api.go
type Config struct {
    Addr string `env:"ADDR" envDefault:":8080"`
}
func NewConfig(config.Source) (*Config, error)
func New(cfg *Config, db *postgres.DB) *Server
func (s *Server) Run(context.Context) error
func (s *Server) Stop(context.Context) error
main.go — without servo58 lines
func main() {
    ctx, stop := signal.NotifyContext(
        context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    env := config.NewEnv()

    logCfg, err := logger.NewConfig(env)
    if err != nil {
        log.Fatal(err)
    }
    lg := logger.New(logCfg)

    dbCfg, err := postgres.NewConfig(env)
    if err != nil {
        _ = lg.Stop(context.Background())
        log.Fatal(err)
    }
    db, err := postgres.New(dbCfg, lg)
    if err != nil {
        _ = lg.Stop(context.Background())
        log.Fatal(err)
    }
    if err := db.Init(ctx); err != nil {
        _ = db.Stop(context.Background())
        _ = lg.Stop(context.Background())
        log.Fatal(err)
    }

    apiCfg, err := api.NewConfig(env)
    if err != nil {
        _ = db.Stop(context.Background())
        _ = lg.Stop(context.Background())
        log.Fatal(err)
    }
    srv := api.New(apiCfg, db)

    errc := make(chan error, 1)
    go func() { errc <- srv.Run(ctx) }()
    select {
    case err := <-errc:
        log.Print(err)
    case <-ctx.Done():
    }

    // Reverse order, and every one still runs
    // when an earlier one fails.
    sctx, cancel := context.WithTimeout(
        context.Background(), 30*time.Second)
    defer cancel()
    for _, shutdown := range []func(context.Context) error{
        srv.Stop, db.Stop, lg.Stop,
    } {
        if err := shutdown(sctx); err != nil {
            log.Print(err)
        }
    }
}
main.go — with servo22 lines
func main() {
    ctx, stop := signal.NotifyContext(
        context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    app, err := New(ctx)
    if err != nil {
        log.Fatal(err)
    }
    if err := app.Run(ctx); err != nil {
        log.Print(err)
    }

    // Not ctx: it is already cancelled, and that
    // is what started the shutdown.
    sctx, cancel := context.WithTimeout(
        context.Background(), 30*time.Second)
    defer cancel()
    if r := app.Shutdown(sctx); !r.Clean() {
        log.Print(r)
    }
}

Add a component and the left column grows in four places at once: a config, a constructor call, another Stop in every error branch below it, and another entry in the teardown list — all of it edits to code that already worked. The right column does not change, because it never names a component.

Why before you build

A missing dependency stops the build, not the deployment.

Runtime containers catch the same mistakes — later, through a reflection stack trace, in whichever environment ran the binary first. Servo reports them with a file and a line, like any other compile error.

api/api.go:15:6: servo: no provider for store.Store needed by *api.Server api/api.go:15:6 root cmd/basic/spec.go:17:3 2 types implement store.Store — add one of: servo.Bind[store.Store, *mockstore.Store]() mockstore/mockstore.go:29:6 servo.Bind[store.Store, *postgres.DB]() postgres/postgres.go:13:6

Ambiguity, dependency cycles and unreachable providers fail the same way. Limitations is honest about what servo cannot resolve at all.

One pipeline, seven views

generate, check, graph, explain, why, list and doctor are not seven tools. They are seven windows onto the same five stages, which run in the same order every time — though only generate and check reach the last one.

flowchart LR
    L["load<br/>one type-checked<br/>go/packages session"]
    F["find spec<br/>roots, Bind, Override,<br/>Scoped, Value, Include"]
    S["scan<br/>every constructor-shaped<br/>function, classified"]
    R["resolve<br/>closure → precedence →<br/>cycles → levels"]
    E["emit<br/>one deterministic,<br/>gofmt-clean file"]

    L --> F --> S --> R --> E

Loading happens once per module, however many injectors it contains. Everything after it runs per injector, because a monorepo’s cmd/api, cmd/worker and cmd/migrator do not share a graph even when they share a type-checking session.

Resolution has exactly two outcomes: a complete ordered plan, or a set of diagnostics. Never a partial graph.

What the generated app does when it runs

New, Run and Shutdown are the whole lifecycle. Start-up follows dependency order and unwinds if any step fails. Shutdown runs the same list backwards under a time budget, and reports anything that refused to stop rather than hanging on it forever.

flowchart TD
    C["Construct<br/>constructors called in dependency order"]
    I["Init<br/>level by level — independent nodes concurrently"]
    OK{"every Init<br/>returned nil?"}
    RB["Roll back<br/>Shutdown(ctx), joined with the failing error"]
    RUN["Running"]
    S["Shutdown<br/>reverse dependency order"]
    B{"returned inside<br/>the stop budget?"}
    DONE["StatusOK / StatusFailed"]
    AB["StatusAbandoned<br/>reported, not waited on"]

    C --> I --> OK
    OK -- no --> RB
    OK -- yes --> RUN --> S --> B
    B -- yes --> DONE
    B -- no --> AB

None of that is a framework callback. It is ordinary Go, in a file you can open, step through in a debugger, and review in a pull request like any other code.

The reference

Every command, every marker, every rule

The tutorial teaches servo by building something with it. The reference answers one question at a time — what this flag does, what this method guarantees, why that function wasn't picked up as a provider. Written against the source, and complete rather than illustrative.

The tutorial

Building a microservice with servo

Twenty-one chapters that build one real order-management service from an empty directory: HTTP API, JWT auth, Postgres, Redis, NATS, metrics, tracing, a circuit breaker, four levels of tests, CI/CD and a container — plus the same API rebuilt in Gin and over gRPC. Every code block is copied from a real module you can run at every step.