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
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.
// 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) errorfunc 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)
}
}
}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.
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 build step
What you run, what you write, and how servo turns one into the other.- CLI commands Every command, every flag, what it prints, and what it exits with.
- Spec file and markers Build, Root, Bind, Override, Scoped, Value, Include, and the build tag that keeps them out of your binary.
- Resolution rules What counts as a constructor, how a dependency is matched, and how order is derived.
- Diagnostics Every message servo can print, what caused it, and what to change.
Beyond one instance
The one thing in the graph that is not built once and held forever.The generated application
The code servo writes, and what it does when it runs.Go packages
Every exported identifier in the two packages you can import.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.
Foundations
The shape of the service, and the groundwork every later layer assumes.Core layers
One layer at a time, each one only talking to the layer below it.- 4 Domain layer Deciding what an order actually is, before any database or HTTP request.
- 5 Repository layer Somewhere to really put an order — Postgres, behind an interface.
- 6 Caching layer Keeping every read off Postgres once volume stops being hypothetical.
- 7 Messaging layer The first layer that talks sideways instead of down — events over NATS.
- 8 Service layer Orchestrating the focused pieces underneath into actual business logic.
Reaching the outside
Making the service callable, knowing who is calling it, and what swapping the transport underneath actually costs.- 9 Authentication Knowing who's asking, and turning away requests that won't say.
- 10 API layer Everything assembled so far, finally reachable over HTTP.
- 11 Gin as the transport The same API in Gin — route groups instead of per-handler wrappers.
- 12 gRPC as the transport The same API again over gRPC, sharing a single port with REST.