Generated API

Who this is for: anyone about to call the generated code from main.go or a test, or reading a servo_gen.go in a pull request and wanting to know which parts are contract and which are incidental.

The generated file is ordinary Go. It is compiler-checked, IDE-navigable, steppable in a debugger, and reviewable like any other file. What follows is exactly what it contains.

What gets written, and where

File When Contains
servo_gen.go Always App, New, the full method set, one registry per declared scope, and Values/NewWith when the spec declares a servo.Value
servo_gen_test.go Only when the spec declares at least one servo.Override TestApp, NewTestApp, the same method set, and TestValues/NewTestAppWith under the same condition

Both land in the same directory as the spec file, in that directory’s package — which is why the spec file belongs next to the main.go that will call New.

servo_gen.go opens with //go:build !servoinject, the mirror image of the spec file’s constraint, so the two are never in one build. servo_gen_test.go is a _test.go file, so the test-only constructor never reaches your binary.

When servo generate is given build flags, the names and the constraints both follow the configuration: --tags=prod writes servo.prod_gen.go gated //go:build !servoinject && prod, alongside rather than instead of the default variant. See Build variants.

Both carry the standard marker:

// Code generated by servo generate. DO NOT EDIT.

Take it literally. Regenerating overwrites the file completely, and there is no merge. Anything you need to add goes in a hand-written file in the same package.

Commit the generated file. A fresh checkout should build without anyone running servo generate first, and servo check in CI is what keeps the committed copy honest. servo doctor warns when it looks untracked.

The header comment

Every generated file documents the graph it was generated from:

// Code generated by servo generate. DO NOT EDIT.
//
// Resolved graph:
//
//	[L1] *example.com/servobasic/logger.Logger
//	      deps: none
//	      capabilities: Finalizer | binding: sole candidate | logger/logger.go:10:6
//	[L2] *example.com/servobasic/postgres.DB
//	      deps: *example.com/servobasic/logger.Logger
//	      capabilities: Initializer, Finalizer, Healther | binding: explicit bind | postgres/postgres.go:13:6
//	[L3] *example.com/servobasic/api.Server
//	      deps: *example.com/servobasic/postgres.DB
//	      capabilities: Runner, Drainer, Finalizer, Readier | binding: sole candidate | api/api.go:15:6

[Ln] is the level, binding is why that provider was chosen, and the position is relative to the module root so the file is byte-identical across checkouts. This block is the reason a reviewer can see the effect of a signature change in the diff: a new dependency shows up here, not just in a call site.

A supplied value is listed first, at [L0], with the servo.Value[…]() call site in place of a provider:

//	[L0] example.com/app/conf.Flags
//	      supplied by the caller  cmd/api/spec.go:13:3

An app with a scope gets a second block per scope, below the node list — its policy, the accessors that expose it, what one instance holds ([Sn] is the level within that scope), and the singletons it borrows from the app:

// scope example.com/servoscoped/chat.RoomKey
//
//	linger: 30s | max: 10000
//	accessor: example.com/servoscoped/chat.Rooms -> *example.com/servoscoped/chat.Room
//	[S1] *example.com/servoscoped/chat.RoomLog
//	      capabilities: Initializer, Flusher
//	[S2] *example.com/servoscoped/chat.Room
//	      capabilities: Initializer, Runner, Drainer, Finalizer
//	borrows: *example.com/servoscoped/logger.Logger

The App type

type App struct {
	logger           *logger.Logger
	loggerStopOnce   sync.Once
	loggerStopResult servo.NodeResult
	db               *postgres.DB
	dbStopOnce       sync.Once
	dbStopResult     servo.NodeResult
	server           *api.Server
	serverStopOnce   sync.Once
	serverStopResult servo.NodeResult
	consumer         *worker.Consumer
	ordersAccount    *queue.OrdersAccount
	auditAccount     *queue.AuditAccount
	relay            *relay.Relay
	startupReport    servo.StartupReport
}

One field per node, in construction order, plus bookkeeping only where it’s needed:

Field Emitted when
<name> Always — the component itself, or, for a supplied value, the value copied out of Values
<name>Cleanup func() The constructor returned a cleanup func
<name>StopOnce sync.Once The node has something to stop (Drain, Flush, Stop, or a cleanup func)
<name>StopResult servo.NodeResult Same condition — memoises the result so the stop path is idempotent
startupReport Always

In the example above, consumer implements only Runner and the two queue accounts implement nothing, so neither gets stop bookkeeping. That’s the general rule: you pay for the capabilities you implement and nothing else.

When the spec declares a scope, the App gains four more fields per scope:

Field Purpose
<key>Scope *<key>Scope The registry: the instance map, the policy, the counters
<iface> One accessor value per exposed root, passed to whatever consumer asked for that interface
<key>ScopeStopOnce sync.Once Same idempotence bookkeeping every stoppable node gets
<key>ScopeStopResult servo.NodeResult The whole scope’s merged outcome — one result, not one per instance

The registry, its entry type and its accessors are package-level declarations in the same file. The override variant prefixes all of them with test, since both files land in the same package.

Every field is unexported. The generated file is part of your package, so code in that package — including its tests — can reach any component directly, and code outside it cannot. That boundary is deliberate: a graph node is not a service locator. If another package needs a component, pass it in as a dependency.

Field names

Derived from the type’s own name, so the generated code reads like something a person wrote:

Rule Type Field
First letter lowercased *api.Server server
All-caps names lowercase entirely *postgres.DB db (not dB)
Collisions get a readable numeric suffix two Pool types pool, pool2
Keywords and predeclared names are avoided *pkg.Range, *pkg.New range2, new2
A scope is named for its key type chat.RoomKey roomKeyScope
An accessor is named for its interface chat.Rooms rooms

That last rule covers both the illegal case (range can’t be an identifier) and the merely unwise one (new would legally shadow the builtin — fine for the compiler, not something to emit into generated code).

Field names are an implementation detail: they can change if you rename a type, and they are not part of the stable API. Tests that reach into fields are reaching into your own package, which is fine — just expect to update them when you rename things.

Method set

This is the stable surface. Every generated App has exactly these methods.

New

func New(ctx context.Context) (*App, error)

Constructs every node in dependency order, then runs the Init phase. Returns a ready application, or nil and an error. Never a partially initialised app. Full semantics, including the two distinct rollback paths, in Lifecycle.

The ctx is used for Init; the rollback paths strip its cancellation, so a signal arriving during a slow startup unwinds properly instead of abandoning every node at once. Pass the context that carries your signal handling.

Values and NewWith

Emitted only when the spec declares at least one servo.Value. A graph that declares none emits the file it always did, byte for byte.

// 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) {  }

NewWith is the one to call. It carries the real body — everything New does above, plus an assignment per supplied value at the very top of construction, before any constructor runs. A supplied value is one per app, held for the life of the process, exactly like a constructed singleton.

New still exists, and delegates with a zero Values{}. That is deliberate: the alternative — dropping New from a graph that declares a value — would make the presence of a marker change the generated package’s public API, which is the one thing the method set is supposed to pin down. It cannot do better than the zero value and its doc comment says so; for a struct of options that is often exactly right, and for a *sql.DB it is nil.

Field names are exported and allocated separately from App’s. They come from the type’s own name (conf.FlagsFlags), in a namespace of their own, because Values is a struct you write a literal for rather than an internal container. Like App’s field names they are not a stable API: rename the type and the field follows.

In the test variant the three names are TestValues, NewTestAppWith and NewTestApp, for the same reason TestApp exists — both files land in the same package.

Run

func (a *App) Run(ctx context.Context) error

Runs every Runner until they return, one failing runner cancelling the rest. Returns nil immediately if the graph has no runners. Does not shut anything down — see Lifecycle.

Shutdown

func (a *App) Shutdown(ctx context.Context) servo.Report

Stops every stoppable node in reverse dependency order, each phase under a budget, and returns a per-node servo.Report. Idempotent. Never returns an error type; check report.Clean(). Details, including the second-signal force-exit, in Lifecycle.

Health and Ready

func (a *App) Health(ctx context.Context) servo.Report
func (a *App) Ready(ctx context.Context) servo.Report

Call every Healther / Readier once, in construction order, and report each result. Flat, with no transitive aggregation, and not called automatically by anything. A graph with no such components returns an empty report, which is Clean(). See Lifecycle.

Graph

func (a *App) Graph() servo.Graph

Returns the resolved graph as data: every node’s type string, level, dependencies, capabilities, selected binding, and source position. It is emitted as a compile-time constant — a literal slice in the generated source — so calling it costs nothing and can never disagree with what was actually built.

It is display-only. Type strings are labels, not lookup keys, and there is no path from a GraphNode back to the instance it describes. Useful for a /debug/graph endpoint, a startup log line, or asserting graph shape in a test.

It serialises byte-for-byte the way servo graph --format=json does, and that is a checked property rather than an aspiration: the two paths populate the identical servo.Graph struct, both write nil (JSON null) for an empty deps, and both write positions relative to the module root. A consumer written against one works against the other — which was the point of the claim, and is now true of the bytes as well as the schema.

A supplied value appears here too, first and at Level: 0, with Binding: "supplied" and the servo.Value[…]() call site as its Pos.

Scoped nodes appear here too, after the singletons, each carrying its scope’s key in Scope and its level within that scope in Level. The scopes themselves are listed in Graph.Scopes, with their policy, their accessor interfaces, their members and the singletons they borrow. Both fields are omitempty: an app with no scopes serialises exactly as it did before scopes existed.

Scope accessors

A scope adds no public method to App — only an unexported stop<Key>Scope, alongside the per-node stop<Name> methods. What consumers see is a field, satisfying the interface you declared:

type roomsAccessor struct{ s *roomKeyScope }

func (x roomsAccessor) Acquire(ctx context.Context) (*chat.Room, func(), error) { ... }
func (x roomsAccessor) Stats() servo.ScopeStats                                { ... }

That shape is part of the contract — an interface declaring anything else is a generate-time diagnostic. Everything the accessor does is documented on Scoped instances.

Report

func (a *App) Report() servo.StartupReport

Returns per-node Init durations recorded during New: which component cost what, with no external instrumentation. Only nodes implementing Initializer appear. Within a level whose nodes ran concurrently, entries are in completion order.

Call it right after New succeeds — most usefully paired with Shutdown’s report in servotest.NewRecorder to assert real ordering.

stop<Name> (unexported)

func (a *App) stopServer(ctx context.Context) servo.NodeResult

One per stoppable node, called by Shutdown and by construction rollback. Guarded by that node’s sync.Once, so both callers can invoke it and the component’s teardown still happens once. Unexported deliberately: shutdown order is a property of the graph, and stopping one node out of order is not a supported operation.

The test variant

When the spec declares a servo.Override, servo generate emits a second file with a separate type:

func NewTestApp(ctx context.Context) (*TestApp, error)

TestApp has the same method set as App and is otherwise a distinct type — not an alias, not a wrapper. It has to be. Overriding an interface can resolve a completely different concrete type, which means different fields, different capabilities, and a different set of stop methods; the two graphs cannot share one struct definition.

Every generated name is prefixed the same way, for the same reason: a spec that also declares a servo.Value gets TestValues and NewTestAppWith beside NewTestApp, and the scope registries and accessors get a test prefix.

That distinctness has one consequence worth planning for. The two graphs are resolved separately, so a servo.Value that only the production graph depends on is unused in the override graph — and an unused value is a diagnostic. If overriding an interface removes the only consumer of a supplied value, either the mock has to take it too or the value has to go.

Because it lives in a _test.go file, it exists only under go test. A typical test:

func TestApp(t *testing.T) {
	defer servotest.NoLeaks(t)
	servotest.Timeout(t, 50*time.Millisecond)

	ctx := context.Background()
	app, err := NewTestApp(ctx)
	if err != nil {
		t.Fatal(err)
	}

	app.storeMock.GetFunc = func(key string) string { return "mocked:" + key }
	got := app.server.Lookup("user:42")
	// ...

	rec := servotest.NewRecorder(app.Report(), app.Shutdown(ctx))
	servotest.AssertStopOrder(t, rec, "*api.Server", "*postgres.DB")
}

The mock is reachable as a field because the test is in the same package as the generated file — the same boundary that keeps it unreachable from anywhere else.

Two things to know: servo check compares servo_gen.go only, so a drifted override variant is not reported by it (a go test run is what catches that); and an override applies to the entire graph, never to one consumer.

What is stable

Stable, and safe to write code against: the method set above, its signatures, and the semantics of each — including that New(ctx) exists and keeps that signature whether or not the spec declares a servo.Value, and that NewWith/Values are the names used when it does. The JSON schema of servo.Graph. The types in the servo package.

Not stable: App field names, Values field names, the exact statements emitted, the header comment’s formatting, variable names inside New. Treat the file’s contents as an implementation detail of your own package — you commit it, you review it, you don’t depend on its internals, and you never edit it.