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.

Declare a route as a comment — //servo:post /order/{category}/ on a plain function — and the HTTP server is generated too.

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, eight views

generate, check, graph, explain, why, list, config and doctor are not eight tools. They are eight windows onto the same six 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, HTTP, Value,<br/>Include, ConfigFile"]
    S["scan<br/>every constructor-shaped<br/>function, classified"]
    RT["routes<br/>every //servo: directive,<br/>validated module-wide"]
    R["resolve<br/>closure → precedence →<br/>cycles → levels"]
    E["emit<br/>one deterministic,<br/>gofmt-clean file"]

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

Loading happens once per module, however many injectors it contains, and so does the route scan: a //servo:get /healthz comment declares an HTTP route wherever it lives, and a spec declaring servo.HTTP() gets the servers for it emitted — mux, typed request decoding, middleware, lifecycle. Everything else 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; emitted HTTP servers are constructed last, serve inside Run, and drain first. 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.

Beyond one instance

The one thing in the graph that is not built once and held forever.
  1. Scoped instances Keyed, refcounted instances — one per room, per tenant, per region.

Observability

Spans around the dependencies you already have, generated rather than hand-written.
  1. Traced interfaces The Trace marker, the Expected option, the generated wrapper, and the tracer provider it depends on.

Configuration

Settings parsed by generated code — no reflection, no per-package boilerplate.
  1. Generated configuration The //servo:config directive, its four-word tag grammar, the ConfigFile marker, and the loader servo writes.

The tutorial

Building a microservice with servo

Twenty-two 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, over gRPC, and as generated //servo: routes. Every code block is copied from a real module you can run at every step.