servo

module
v3.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 29, 2026 License: MIT, MIT

README

Go Tutorial CodeQL OpenSSF Best Practices codecov Go version Go Reference Docs

servo v3

Servo is a build-time code generator that resolves a Go application's object graph from constructor signatures and emits plain Go source that constructs, starts, supervises, and shuts down the application in dependency order.

No reflection. No runtime registry. No init(). No hand-written wiring.

The generated file is ordinary Go: compiler-checked, IDE-navigable, steppable in a debugger, and readable by a human at 3am.

Documentation: okian.github.io/servo — start with the preface if dependency injection is new to you, how servo compares if you are weighing it against wire, fx, or dig, and limitations before you adopt it. The reference documents every CLI command and flag, the spec-file markers, the resolution rules, every diagnostic, scoped instances, the lifecycle contract, the generated API, and every exported identifier in servo and servotest.

v3 is a from-scratch rewrite of servo and shares no API with what came before it (informally, v1). v1 was a runtime lifecycle sequencer built on a global registry, a hand-maintained order int, and Initialize(ctx) error with no parameters — components found each other through package-level globals. Its own last breaking change had already taken the import path to /v2; this rewrite is different enough to need /v3. Dependencies are now declared only by constructor parameters, and resolution happens at build time, not runtime.

See ARCHITECTURE.md for how the load → scan → resolve → emit pipeline fits together and why it's shaped the way it is, and CHANGELOG.md for what's changed and how this project versions releases. If you'd rather learn servo by building something with it than by reading API docs, docs/tutorial/ builds a complete, real microservice — Postgres, Redis, NATS, JWT auth, OpenTelemetry, CI/CD, the works — layer by layer, against a separate runnable module at examples/tutorial.

Quick start

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

That's the CLI you run by hand — init, explain, why, doctor. Generation itself runs through go generate, against a version pinned in your own go.mod; servo init sets that up below.

Write ordinary constructors — no import of servo required:

// store/store.go
package store

type Store interface{ Get(key string) string }

// postgres/postgres.go
package postgres

func New(log *logger.Logger) (*DB, error)      { ... }
func (d *DB) Get(key string) string            { ... } // satisfies store.Store
func (d *DB) Init(ctx context.Context) error   { ... } // connect
func (d *DB) Stop(ctx context.Context) error   { ... } // disconnect
func (d *DB) Health(ctx context.Context) error { ... }

// api/api.go
package api

func New(s store.Store) *Server { ... } // depends on the interface, not postgres directly

Scaffold the spec file (servo init) and declare the roots — everything they transitively depend on is what gets built; nothing else is:

//go:build servoinject

package main

import (
	"example.com/app/api"
	"example.com/app/postgres"
	"example.com/app/store"
	"github.com/okian/servo/v3/servo"
)

func wire() {
	servo.Build(
		servo.Root[*api.Server](),
		servo.Bind[store.Store, *postgres.DB](), // needed once a 2nd store.Store exists; harmless here
	)
}

servo init writes a second file beside it, and the second file is the one that runs the generator:

// cmd/app/servo_generate.go — no build tag, deliberately
package main

//go:generate go tool servo generate

The directive cannot live in the spec file: go generate honours build constraints, so a directive behind the inactive servoinject tag is never reached — go generate ./... exits 0, prints nothing, and generates nothing. And it is go tool servo, not go run github.com/okian/servo/v3/cmd/servo: a consumer requires servo for the marker package alone, so the generator's own dependencies aren't in their build list and go run fails on a missing go.sum entry. The tool directive puts the generator in go.mod, which also pins the version — the thing that decides whether a developer and CI produce the same file. Add it once per module, which is what servo init tells you to do:

go get -tool github.com/okian/servo/v3/cmd/servo

Then go generate ./... emits servo_gen.go next to the spec file, containing New, Run, Shutdown, Health, Ready, Graph, and Report — construction in dependency order, lifecycle calls only for the capabilities a type actually implements, reverse-order shutdown with budgets and a per-node report. main.go never grows with the graph — it stays this same shape:

// servo.RunStop caps each node at servo.DefaultStopBudget, but nothing caps
// their sum, so the whole teardown gets a deadline of its own.
const shutdownTimeout = 30 * time.Second

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 cancellation is what started
	// the shutdown. Not a bare context.Background() either, so the unwind
	// cannot outlast the grace period it is running inside.
	sctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
	defer cancel()
	if r := app.Shutdown(sctx); !r.Clean() {
		log.Print(r)
	}
}

A complete, runnable version of the above — including a genuinely ambiguous interface binding, an error-returning constructor with rollback, and every lifecycle capability — lives in examples/basic.

Interfaces vs. concrete types

A dependency's declared type decides how it resolves — there's no separate mode to opt into:

  • Parameter is an interface (func New(s store.Store) *Server): Servo scans every candidate in scope for the one whose result type structurally implements it (types.Implements, checked at generation time, never at runtime). Exactly one implementation auto-binds. Zero is a missing-provider error; two or more is an ambiguity error, resolved by adding servo.Bind[store.Store, *postgres.DB]().
  • Parameter is a concrete type (func New(l *logger.Logger) *DB): Servo looks for the one function that returns exactly that type. No interface is involved and Bind doesn't apply — there's nothing to disambiguate unless a second function also returns that same concrete type (see Multiple instances of the same type below).

An explicit Bind always wins even over an otherwise-unambiguous interface match, so it also works as a deliberate override, not just a tie-breaker.

Declare an interface where a component should accept any of several implementations; declare a concrete type where there's exactly one. Servo follows whichever you wrote — it never converts one into the other, and nothing about the shape of a dependency changes based on whether other parts of the graph happen to use interfaces or concrete types elsewhere.

Values the caller supplies

Both rules above end at some function's result. A value that only exists once the process is already running has no such function — a parsed flag set, a version string injected with -ldflags, a DSN assembled from the environment — and the workaround servo used to leave open, a package-level var in main read back by a small provider beside it, is the global lookup this tool exists to remove. servo.Value[T]() declares that T comes from the caller instead:

servo.Build(
	servo.Root[*api.Server](),
	servo.Bind[store.Store, *postgres.DB](),
	servo.Value[conf.Flags](),
)

Declaring one changes the generated API additively — New stays, and a struct plus a second constructor appear beside it:

// Values carries the values servo.Value declares: the ones the caller
// supplies rather than any provider builds.
type Values struct {
	Flags conf.Flags
}

// New builds the app with the zero value of every servo.Value.
// Prefer NewWith: the zero value is a real value for a struct of
// options and a nil pointer for anything else, so this is right only when
// the zero value is what you meant.
func New(ctx context.Context) (*App, error) {
	return NewWith(ctx, Values{})
}

func NewWith(ctx context.Context, v Values) (*App, error) {
	a := &App{}

	flags := v.Flags
	a.flags = flags

	db := postgres.New(flags)
	a.db = db
	...

(verbatim from a generated file, cut at the ellipsis; the test-override variant names the three TestValues, NewTestAppWith, and NewTestApp.)

A supplied value is matched by type exactly as a constructor parameter is, and beats any provider that also produces that type — declaring one is how you say "this comes from the caller", which is only meaningful if it wins. It has no provider, no dependencies and no lifecycle: servo didn't build it, so servo doesn't stop it. It is an ordinary level-0 node everywhere else:

$ servo explain conf.Flags
example.com/app/conf.Flags
  provider:     the caller, via NewWith (cmd/app/spec.go:17:3)
  binding:      supplied
  lifetime:     supplied — handed to NewWith once, held for the life of the process
  level:        0
  depends on:   none
  depended on:  *example.com/app/postgres.DB
  capabilities: none

(absolute path shortened)

"Once" is the boundary worth reading twice: Values is filled in when the app is constructed, so this is a per-app value and not a per-call one — a *testing.T still has no way in (see Mocking). A declared value nothing in the graph depends on is a generate-time diagnostic, not an unused struct field, and a graph declaring no value at all emits exactly the file it always did. Full contract: Value.

Wiring shared between several injectors

A monorepo's cmd/api, cmd/worker and cmd/migrator each declare their own graph, and below the transport they are usually the same graph. servo.Include(fn) splices another function's marker list into a Build call, so the shared part is written once:

// internal/wiring/wiring.go
//go:build servoinject

package wiring

func Shared() []servo.Marker {
	return []servo.Marker{
		servo.Bind[repository.OrderRepository, *postgres.Store](),
		servo.Scoped[*session.Session, session.Sessions](servo.Linger(5 * time.Minute)),
		// ...
	}
}
// cmd/api/spec.go
servo.Build(
	servo.Include(wiring.Shared),
	servo.Root[*api.Server](),
)

examples/tutorial is the worked case: three binaries wiring one service layer behind net/http, Gin, and gRPC. Each spec used to be 53 lines and eleven marker calls, ten of them identical in all three, so adding a fifth Bind or changing a Max was a three-file edit nothing checked. Those ten now live in one internal/wiring/wiring.go and each spec is 19 lines — the Include, and the one thing that actually differs: its own transport's Root. (Linger and Max are ScopeOptions inside the Scoped call, not Build arguments, so they are not among the eleven.)

The named function is read, never called — the same rule the spec file lives by, which is why the shape it may have is narrow. Its body has to be exactly return []servo.Marker{...}; a variable, a conditional or an append is refused, because answering those would mean running a program the spec file deliberately isn't. It may live in another package, and its file needs the servoinject tag for the same reason a spec file does: every marker in it panics if it ever executes. An included set may itself Include; a cycle is a diagnostic naming the path that closed it, not a hang.

Markers arrive where the Include sits, so a Bind written after it in the spec file overrides the shared one — the local file has the last word, which is the only ordering that makes a shared set worth having. (Two Binds for the same interface written in the same file is still the duplicate error it always was.) Full contract, including every refusal: Include.

Capabilities

A component implementing none of these is just constructed and held; no lifecycle code is emitted for it. Components never import servo to implement them — detection is structural (types.Implements), checked entirely at generation time:

type Initializer interface{ Init(ctx context.Context) error }
type Runner      interface{ Run(ctx context.Context) error }
type Drainer     interface{ Drain(ctx context.Context) error }
type Flusher     interface{ Flush(ctx context.Context) error }
type Finalizer   interface{ Stop(ctx context.Context) error }
type Healther    interface{ Health(ctx context.Context) error }
type Readier     interface{ Ready(ctx context.Context) error }

Init runs in topological order, one node per level or an errgroup across a level with more than one. Run launches every Runner into an errgroup.WithContext, so one failing runner cancels the rest. Shutdown runs in reverse-topological order — Drain, then Flush, then Stop (plus any cleanup func the constructor returned) — each phase budgeted, reported as stopped, failed, or abandoned, never claiming a clean stop it didn't earn. A second interrupt/term signal during shutdown forces an immediate exit.

Scoped instances

Everything above is a singleton: constructed once in New, held for the life of the process. One thing isn't. A scope is a keyed sub-graph — one chat room per room name, one workspace per tenant, one client pool per region — where everyone presenting the same key shares an instance, and the instance is drained, stopped and evicted once the last holder lets go.

Declare how to extract the key, on the type itself:

// chat/chat.go
type RoomKey string

// The receiver must be unnamed: servo calls this on a typed nil, because it
// needs the key before it can choose an instance.
func (*Room) ScopeKey(ctx context.Context) (RoomKey, error) {
    k, ok := ctx.Value(roomCtxKey{}).(RoomKey)
    if !ok {
        return "", servo.ErrNoScopeKey
    }
    return k, nil
}

// The accessor interface, declared in your package — servo can't emit a
// type into it, so this is what consumers depend on.
type Rooms interface {
    Acquire(ctx context.Context) (*Room, func(), error)
}

Declare the scope in the spec, alongside the roots:

servo.Build(
    servo.Root[*api.Server](),
    servo.Scoped[*chat.Room, chat.Rooms](
        servo.Linger(30*time.Second),
        servo.Max(10_000),
    ),
)

Consume it as an ordinary dependency, and acquire per call:

func NewServer(rooms chat.Rooms, log *logger.Logger) *Server

func (s *Server) Post(ctx context.Context, msg string) error {
    room, release, err := s.rooms.Acquire(ctx)
    if err != nil {
        return err
    }
    defer release()

    room.Post(msg)
    return nil
}

The map, the reference count, the linger timer and the per-instance Init/Run/Drain/Flush/ Stop are all generated. Anything the room transitively depends on that also depends on the key joins the same scope; anything that doesn't — a logger, a config — stays one shared instance.

The check that makes this worth generating rather than hand-writing is the one a registry beside servo can't give you:

$ servo generate --dir examples/scoped
example.com/servoscoped/cmd/chat: servo: 1 diagnostic(s):

api/api.go:19:6: servo: *example.com/servoscoped/chat.Room is scoped, but *example.com/servoscoped/api.Server is a singleton that depends on it
  needed by *example.com/servoscoped/api.Server  api/api.go:19:6
  root                                           cmd/chat/spec.go:15:3

  A singleton is constructed once and held for the life of the process, so it
  would capture whichever *example.com/servoscoped/chat.Room happened to be built first and hand that same
  one to every caller afterwards, whatever key they present. Nothing about the
  running program would say so.

  Two ways out:
    - depend on the accessor instead: change api.New's parameter from *example.com/servoscoped/chat.Room to example.com/servoscoped/chat.Rooms,
      and call Acquire(ctx) per request
    - make *example.com/servoscoped/api.Server scoped too, by giving it a dependency on example.com/servoscoped/chat.RoomKey

(reproduced by changing api.New's rooms parameter from chat.Rooms to *chat.Room in examples/scoped and dropping the two Acquire calls it feeds; absolute paths shortened)

A complete, runnable version — including a transitively-scoped node, a shared singleton, and the race suite that gates the feature in CI — is in examples/scoped. The full contract is documented at Scoped instances: the extractor's rules, what belongs to a scope, the linger window, the Max cap, the teardown ordering, and the four diagnostics.

Multiple instances of the same type

This section is about a narrower problem than interface vs. concrete-type resolution above: two constructors returning the exact same concrete type, with no interface involved at all. Identity in the graph is purely by type — there is no tagging mechanism (the Key type carries a Tag field for it, but nothing in the public API sets one yet). So two constructors returning the same type aren't two instances, they're an ambiguity:

$ servo generate
servo: no provider for *sqsaccounts.Client
  2 functions produce *sqsaccounts.Client — remove or rename all but one:
      processor.NewClientA   processor/naive.go:5:6
      processor.NewClientB   processor/naive.go:6:6

The fix — needed for two SQS clients on two AWS accounts, a primary and a replica database, two tenant databases, anything of this shape — is to make the two instances distinct types, not just distinct values of one type. examples/basic/queue and examples/basic/relay are a complete, generated, tested example:

// queue/queue.go
type Client struct{ Account string } // shared underlying client

// Distinct types are what makes these two separate, unambiguous graph
// nodes — both wrap the same Client, each constructed against a
// different account's credentials.
type OrdersAccount struct{ *Client }
type AuditAccount struct{ *Client }

func NewOrdersAccount() *OrdersAccount { return &OrdersAccount{Client: &Client{Account: "111111111111"}} }
func NewAuditAccount() *AuditAccount   { return &AuditAccount{Client: &Client{Account: "222222222222"}} }

A component that needs both just takes both — embedding means every *Client method (Send, here) is still directly available, no delegation boilerplate:

// relay/relay.go — forwards an order event from the orders account's
// queue into the audit account's queue, a realistic reason one component
// needs two distinct accounts of the same underlying type at once.
func New(orders *queue.OrdersAccount, audit *queue.AuditAccount) *Relay {
	return &Relay{orders: orders, audit: audit}
}

func (r *Relay) Init(ctx context.Context) error {
	r.OrdersResult = r.orders.Send("order-created")
	r.AuditResult = r.audit.Send("order-created (audit copy)")
	log.Println(r.OrdersResult)
	log.Println(r.AuditResult)
	return nil
}

servo explain relay.Relay confirms exactly the shape you'd expect — two independent level-1 nodes, both feeding the one level-2 consumer that needs them:

$ servo explain relay.Relay
*example.com/servobasic/relay.Relay
  provider:     relay.New (relay/relay.go:24:6)
  binding:      sole candidate
  lifetime:     singleton — one per process, built by New
  level:        2
  depends on:   *example.com/servobasic/queue.OrdersAccount, *example.com/servobasic/queue.AuditAccount
  depended on:  none
  capabilities: Initializer

Running the example logs both, distinctly, at startup (timestamps from Go's log package trimmed below):

[account 111111111111] order-created
[account 222222222222] order-created (audit copy)

If nothing ever needs just one of the two on its own, a simpler alternative is a single component holding both as plain fields (type Clients struct{ Orders, Audit *queue.Client }) instead of two separate graph nodes — reach for distinct types when different consumers need different instances, and a bundling struct when they're always needed together.

Diagnostics

Missing providers, ambiguous bindings, and cycles are build failures with source positions — never a runtime panic, never a guess:

$ servo generate
servo: no provider for example.com/servobasic/store.Store
  needed by *example.com/servobasic/api.Server  api/api.go:15:6
  root                                          cmd/basic/spec.go:17:3

  3 types implement example.com/servobasic/store.Store — add one of:
      servo.Bind[example.com/servobasic/store.Store, *example.com/servobasic/memory.Store]()      memory/memory.go:15:6
      servo.Bind[example.com/servobasic/store.Store, *example.com/servobasic/mockstore.Store]()      mockstore/mockstore.go:29:6
      servo.Bind[example.com/servobasic/store.Store, *example.com/servobasic/postgres.DB]()      postgres/postgres.go:13:6

(reproduced by deleting the servo.Bind[store.Store, *postgres.DB]() line from examples/basic/cmd/basic/spec.go and running servo generatemockstore.Store shows up too because it's a real second-and-third implementation living in the same module, used by the Mocking section below)

examples/diagnostics has eight small, permanently broken fixtures — one per failure mode above, a dependency cycle, and one for each of the four scope diagnostics, with both halves of the undeclared-scope one (a ScopeKey method no servo.Scoped names, and a servo.Scoped naming a type with no ScopeKey method) — each runnable on its own (go run ./cmd/servo generate --dir examples/diagnostics/<name>) to see exactly that diagnostic in isolation.

CLI

Command Purpose
servo generate [--dir] Resolve and emit servo_gen.go for every injector found under --dir (and servo_gen_test.go per injector that declares servo.Override). Default command.
servo check [--dir] Verify every injector found under --dir matches a fresh generation; prints a diff and reports every stale one, not just the first.
servo graph [--dir] [--format=text|json|dot|mermaid] Export one injector's resolved graph.
servo explain <type> [--dir] Which provider was selected and why, its dependencies, dependents, level, and capabilities.
servo why <type> [--dir] Shortest path from a root to that node.
servo list [--rejected] [--all] [--dir] The candidate index, or every excluded function and the rule that excluded it. Defaults to the main module; --all includes stdlib/third-party.
servo init [--dir] Scaffold the spec file with the correct build tag, plus an untagged servo_generate.go holding the go:generate directive — and print the one-time go get -tool step.
servo doctor [--dir] Diagnose setup problems (missing build tag, stale/absent generated file) before go generate ever runs.
servo migrate [--dir] Read v1 Register(X{}, N) calls and emit a v3 skeleton plus a report flagging duplicate order values. See examples/migrate for a worked example.
servo new component <Name> / servo new adapter <pkg> Scaffold a component or third-party wrapper. Never imports servo.
servo new mock-adapter <moq|mockery|gomock> <GeneratedTypeName> Scaffold the adapter file a generated mock needs to become a valid provider (see Mocking).
servo version servo <version> <goversion> <os>/<arch>. The stale report from servo check points here: two machines on two servo versions produce a diff that reads exactly like a forgotten regenerate.
servo help [command] The command list, or one command's usage. An unknown command prints the same list rather than a bare rejection.

--dir (default .) is where the module scan starts. generate, check, and doctor process every injector they find within it — a monorepo with cmd/api, cmd/worker, cmd/migrator each wiring their own graph gets all three generated/checked/diagnosed in one pass, the same way wire ./... does, and a CI job doesn't need updating when a new service is added (see examples/basic, whose cmd/basic and cmd/migrator are exactly this). Commands that answer a question about one graph (graph, explain, why, list) instead ask you to disambiguate with --dir when more than one injector is in scope — pointing it at a specific injector's own directory (e.g. --dir cmd/api) scopes the scan to just that one, since a package main can never import another package main, so sibling injectors are structurally unreachable from it.

explain, why, and list accept --json for machine consumption, and graph takes --format=json alongside its text, DOT, and Mermaid renderers. Every command that loads packages also takes the go build flags that decide which files exist — --tags, --mod, --modfile, --overlay — spelled exactly as go build spells them. --tags resolves the graph under those tags and writes a correspondingly constrained generated file, so one injector can hold a default and a --tags=prod variant side by side. examples/variants is exactly that — one injector, two generated files, both built and tested in CI — and Build variants is the contract. servovet is a go/analysis analyzer for the two mistakes the compiler can't catch: a marker call — Build, Root, Bind, Override, Scoped, Value, Include, Linger, Max — in a file missing the servoinject build tag, and a ScopeKey method whose body can reach its own receiver (servo calls it on a typed nil). Both are caught in the editor, not at runtime. The analyzer is an exported servovet.Analyzer, so golangci-lint's module plugin system, a multichecker, or analysistest can import it; cmd/servo-vet is the singlechecker binary wrapping it.

CI and pre-commit: .github/workflows/go.yml is a reference workflow — build, vet, test, then servo check against every injector, so a constructor signature change without a matching re-run of servo generate fails CI instead of shipping a stale generated file. githooks/pre-commit runs the same check locally; enable it in a given clone with git config core.hooksPath githooks (it is not on by default).

Testing (servotest)

func TestApp(t *testing.T) {
	defer servotest.NoLeaks(t) // goleak, clean by construction

	ctx := context.Background()
	app, err := NewTestApp(ctx) // generated only when the spec declares servo.Override
	if err != nil {
		t.Fatal(err)
	}
	...
	rec := servotest.NewRecorder(app.Report(), app.Shutdown(ctx))
	servotest.AssertStopOrder(t, rec, "*api.Server", "*postgres.DB") // asserted, not assumed
}

servotest.Timeout shrinks servo.DefaultStopBudget for one test, so the abandoned-node path — a component that ignores cancellation must be reported abandoned, never claimed clean — costs milliseconds instead of the real five seconds. It does not belong next to NoLeaks: the path it exists to exercise is by construction one that leaves a goroutine running.

func TestHungStoreIsAbandoned(t *testing.T) {
	servotest.Timeout(t, 20*time.Millisecond)
	// No leak check here — this test parks a goroutine on purpose.
	...
}

goleak has no notion of when a goroutine appeared, so that parked one is reported against whichever test calls NoLeaks next — an innocent test, failing for something another one did. Once a package has a test like the above, every other test in it wants the baseline form instead:

defer servotest.NoNewLeaks(t)() // baseline taken now, checked on return

The trailing () is the check, and dropping it is silent: defer servotest.NoNewLeaks(t) compiles, defers taking a baseline until the test is already over, and discards the closure that would have verified anything. Prefer NoLeaks in packages where nothing parks a goroutine deliberately — it also catches a leak inherited from a sibling test, which is a real defect a baseline hides.

Mocking

Servo does not ship a mock generator — generated code is plain Go, and any type satisfying an interface can be wired in via servo.Override. The one thing every mocking library needs from you: a discoverable, dependency-only constructor. servo generate resolves the graph by calling a provider function it found — func F(deps...) T — never by evaluating a composite literal written in a test file. Hand-written fakes already look like this (see examples/basic/mockstore). Generated mocks sometimes don't, because their constructors are built to take a *testing.T/*gomock.Controller for automatic expectation verification — and that's inherently a per-test-function value, not something the graph has. The fix in every case below is a few lines in a separate, hand-written file (never edit a // Code generated ... DO NOT EDIT file — regenerating the mock would erase it).

examples/mocking wires all three integrations below as real, permanent, go test-checked apps (a shared store.Store/api.Server pair, one small binary per tool, each with the actual tool run for real and the generated mock committed) — not just prose. servo new mock-adapter <moq|mockery|gomock> <GeneratedTypeName> scaffolds the adapter file for any of the three directly, instead of retyping the pattern below by hand.

moq

Real, runnable version: examples/mocking/moq.

moq generates a plain struct with function fields and no constructor at all:

type StoreMock struct {
	GetFunc func(key string) string
	// ...
}

Add one, in a separate file:

// mockstore/adapter.go
func NewStoreMock() *StoreMock { return &StoreMock{} }
servo.Override[store.Store, *mockstore.StoreMock](),
app, _ := NewTestApp(ctx)
app.storeMock.GetFunc = func(key string) string { return "mocked:" + key }
got := app.server.Lookup("user:42")   // "mocked:user:42"
app.storeMock.GetCalls()              // [{Key: "user:42"}]
testify + mockery

Real, runnable version: examples/mocking/mockery.

Mockery's Store type is a plain struct (mock.Mock zero-values fine), but its generated constructor requires a *testing.T-shaped value to auto-register AssertExpectations on cleanup:

func NewStore(t interface {
	mock.TestingT
	Cleanup(func())
}) *Store { ... }

That constructor is still a valid provider shape servo could call — which means a second, zero-arg constructor producing the same *Store type would be a genuine ambiguity (two functions producing the identical type; servo correctly refuses to guess which one to use). So wrap instead of competing:

// mocks/adapter.go
type StoreMock struct{ *Store } // embeds *mocks.Store — a distinct result type, no collision

func NewStoreMock() *StoreMock { return &StoreMock{Store: &Store{}} }
servo.Override[store.Store, *mocks.StoreMock](),
app, _ := NewTestApp(ctx)
app.storeMock.On("Get", "user:42").Return("mocked-value")

got := app.server.Lookup("user:42") // "mocked-value"

app.storeMock.AssertExpectations(t) // call this yourself — nothing auto-registered it

This is the cleanest of the three integrations: testify passes t explicitly to AssertExpectations(t) at the point you call it — where a real t is naturally in scope — rather than baking a reporter in at construction time. A failed expectation reports through t normally: no panic, no crash, just a clean, isolated test failure.

go.uber.org/mock (gomock)

Real, runnable version: examples/mocking/gomock.

Same wrapping requirement as mockery, for the same reason (NewMockStore(ctrl) is a valid, colliding provider shape). The harder part is *gomock.Controller itself — it isn't a zero-value struct, and its TestReporter (just Errorf/Fatalf, nothing *testing.T-specific) has to come from somewhere: servotest.PanicReporter supplies one, satisfying gomock's TestReporter structurally without adding a gomock dependency to servotest itself.

// mockgenstore/adapter.go
type MockStoreForServo struct {
	*MockStore
	Finish func()
}

func NewMockStoreForServo() *MockStoreForServo {
	ctrl := gomock.NewController(servotest.PanicReporter{})
	return &MockStoreForServo{MockStore: NewMockStore(ctrl), Finish: ctrl.Finish}
}
app, _ := NewTestApp(ctx)
defer app.mockStoreForServo.Finish() // call this yourself, right after construction
app.mockStoreForServo.EXPECT().Get("user:42").Return("mocked-value")

got := app.server.Lookup("user:42") // "mocked-value"

Be honest about the tradeoff here: Finish belongs in a plain defer in the test, not routed through servo's (T, func()) cleanup shape — that would run it inside servo.RunStop's own goroutine during Shutdown, where an unmet expectation panics. RunStop recovers that panic and reports the node StatusFailed with the panic value and stack rather than killing the process mid-teardown, but the failure then exists only in the shutdown report, so a test that never inspects app.Shutdown(ctx) passes with the expectation unmet. Called directly in the test's own goroutine instead, an unmet expectation still panics (PanicReporter has no t.Fatalf to soften it into) — Go's test runner reports which test failed before it re-panics, but the process still exits, unlike testify's clean, isolated failure. For strict expectation-count verification where that matters, construct and drive the mock directly in an isolated unit test (api.New(mockedStore), with a real gomock.NewController(t)) instead of through Override — the graph-injection path is the right fit for exercising the wiring itself, not for gomock's stricter verification style.

Layout

cmd/servo/            CLI: generate, check, graph, explain, why, list, init, doctor, migrate,
                      new, version, help
cmd/servo-vet/        singlechecker binary wrapping servovet.Analyzer
internal/load/        go/packages → typed syntax, spec-file discovery, Include splicing
internal/graph/       Key, Provider, candidate index, capability detection
internal/resolve/     roots → closure → order, levels, diagnostics
internal/emit/        source emission, import manager, name allocator
internal/render/      text, JSON, DOT, Mermaid graph renderers
servo/                markers + ~430-line runtime
servotest/            NoLeaks, NoNewLeaks, Recorder, AssertStopOrder, Timeout, Linger,
                      PanicReporter
servovet/             the go/analysis Analyzer, importable by golangci-lint and analysistest
examples/basic/       a complete, runnable example (separate module)
examples/scoped/      keyed, refcounted instances + the race suite (separate module)
examples/mocking/     moq/mockery/gomock integrations, one binary each (separate module)
examples/variants/    one injector, two graphs, selected by a build tag (separate module)
examples/diagnostics/ eight permanently-broken fixtures, one per failure mode (separate module)
examples/tutorial/    full layered microservice built in docs/tutorial/ (separate module)
examples/migrate/     a pre-servo codebase for `servo migrate` to read (part of this module)

Core (internal/*, cmd/servo, servovet) depends on nothing beyond golang.org/x/tools; servotest alone depends on go.uber.org/goleak. Neither the runtime package nor any generated output imports reflect, and the generated package compiles with the servo module deleted save for the ~430-line runtime it calls into — both enforced as conformance checks, not just claimed.

Contributing

Bug reports and feature requests go through GitHub Issues; that's also the public archive of past reports and their resolutions. See CONTRIBUTING.md for the dev workflow and what a PR is expected to include, and SECURITY.md to report a vulnerability privately.

Directories

Path Synopsis
cmd
servo command
Command servo is the build-time dependency and lifecycle generator's CLI: resolve, diagnose, emit, and inspect.
Command servo is the build-time dependency and lifecycle generator's CLI: resolve, diagnose, emit, and inspect.
servo-vet command
Command servo-vet is the singlechecker binary wrapping the servo analyzer.
Command servo-vet is the singlechecker binary wrapping the servo analyzer.
examples
migrate
Package legacy stands in for a pre-servo codebase: components register themselves with a global sequencer and a hand-maintained order, the pattern servo migrate reads to produce a starting spec file.
Package legacy stands in for a pre-servo codebase: components register themselves with a global sequencer and a hand-maintained order, the pattern servo migrate reads to produce a starting spec file.
internal
emit
Package emit turns a resolved graph into a single, deterministic, gofmt-clean Go source file: construction, phased lifecycle, supervision, and static graph introspection data.
Package emit turns a resolved graph into a single, deterministic, gofmt-clean Go source file: construction, phased lifecycle, supervision, and static graph introspection data.
graph
Package graph defines the resolved-graph data model: canonical type identity, provider candidates, and structural capability detection.
Package graph defines the resolved-graph data model: canonical type identity, provider candidates, and structural capability detection.
load
Package load wraps golang.org/x/tools/go/packages to load the main module and every transitively imported package in one type-checking session (so identity-based checks like types.Implements are valid across package boundaries), and to locate + parse the servo.Build(...) spec call.
Package load wraps golang.org/x/tools/go/packages to load the main module and every transitively imported package in one type-checking session (so identity-based checks like types.Implements are valid across package boundaries), and to locate + parse the servo.Build(...) spec call.
render
Package render turns a resolved graph into the human/machine formats `servo graph` exports: text, JSON, DOT, and Mermaid.
Package render turns a resolved graph into the human/machine formats `servo graph` exports: text, JSON, DOT, and Mermaid.
resolve
Package resolve implements the selection precedence, cycle detection, leveling, and diagnostics that turn a candidate index and a set of declared roots into a concrete, ordered construction plan.
Package resolve implements the selection precedence, cycle detection, leveling, and diagnostics that turn a candidate index and a set of declared roots into a concrete, ordered construction plan.
Package servo provides the marker functions read by `servo generate` and the small runtime shared by all generated injectors.
Package servo provides the marker functions read by `servo generate` and the small runtime shared by all generated injectors.
Package servotest provides test helpers for generated injectors: goroutine-leak checking, ordering assertions read directly from a generated App's own reports, and a way to shrink stop budgets to exercise the abandoned-node path without a slow suite.
Package servotest provides test helpers for generated injectors: goroutine-leak checking, ordering assertions read directly from a generated App's own reports, and a way to shrink stop budgets to exercise the abandoned-node path without a slow suite.
Package servovet is the go/analysis analyzer for the two servo mistakes the compiler cannot catch.
Package servovet is the go/analysis analyzer for the two servo mistakes the compiler cannot catch.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL