Spec file and markers

Who this is for: anyone writing or changing the one file that tells servo what to build.

The spec file is the entire input you author. It declares the roots of the object graph and any bindings that resolution can’t work out on its own — and nothing else. It is read as syntax and never executed, which drives almost every rule on this page.

What a spec file is

//go:build servoinject

package main

import (
	"example.com/servobasic/api"
	"example.com/servobasic/mockstore"
	"example.com/servobasic/postgres"
	"example.com/servobasic/relay"
	"example.com/servobasic/store"
	"example.com/servobasic/worker"
	"github.com/okian/servo/v3/servo"
)

func wire() {
	servo.Build(
		servo.Root[*api.Server](),
		servo.Root[*worker.Consumer](),
		servo.Root[*relay.Relay](),
		servo.Bind[store.Store, *postgres.DB](),
		servo.Override[store.Store, *mockstore.Store](),
	)
}

That is examples/basic’s real spec file, using three of the six markers. The other three — Scoped, Value and Include — are on this page too.

Note what is not in it: the //go:generate directive. That lives in an untagged servo_generate.go beside it, because go generate honours build constraints and would never reach a directive inside a //go:build servoinject file. See the go:generate directive.

Four facts about it that matter:

The file’s directory decides where the output goes. servo generate writes servo_gen.go into the same directory as the spec file. Put the spec next to the main.go that will call the generated New.

The function name is a convention, nothing more. Servo searches for a call to servo.Build; it never looks for a function called wire. The name exists so a human reading the file knows why it’s there.

wire is never called, by anything. Nothing in your program references it, and the file never reaches your binary — under the servoinject build configuration it is just an ordinary declared function that nothing happens to call.

Only the Build call is read. The rest of the file can contain whatever you like. It has to be valid Go that type-checks under the servoinject tag, but servo extracts nothing from it.

The build tag

//go:build servoinject

This is required. A spec file without it is a hard error:

spec.go:16:2: spec file is missing a `//go:build servoinject` constraint —
as written it would compile into the real binary

The reason is the markers themselves. Every one of them — Build, Root, Bind, Override, Scoped, Value, Include, Linger, Maxpanics when executed. They exist to be read, and a marker call that actually runs means generation was skipped or the tag was missing. Rather than silently returning a nil app, they fail loudly. The build tag is what guarantees they never run: servo generate loads your module with -tags=servoinject, so it sees the spec file, and an ordinary go build doesn’t.

The same requirement extends to any file Include pulls markers from, even though that file is not a spec file: it declares marker calls, so it would compile them into your binary.

The generated file carries the mirror-image constraint, so the two files are never in the same build at once. That’s also why generation never trips over its own previous output.

Mirror image means your whole constraint, with servoinject negated — not a fixed !servoinject. A spec gated //go:build servoinject && !prod generates a file gated //go:build !servoinject && !prod. Everything you wrote besides the tag survives untouched, which is what lets one injector have several generated files that exclude each other: you express the exclusion in your own spec files, and servo carries it across. See Build variants.

The check is on meaning, not on text. Servo parses the constraint and asks whether it can only be satisfied when servoinject is set, evaluating every other tag as true. So //go:build servoinject && linux passes (it still requires the tag), while //go:build servoinject || linux does not (it can be satisfied on Linux without the tag). Both //go:build and the legacy // +build syntax are recognised.

servo-vet runs this identical check as an analyzer, so a missing tag surfaces in your editor rather than at go generate time.

Spec discovery

Servo scans the main module only for servo.Build(...) calls. Packages from dependencies are loaded and scanned for candidate providers, but a Build call in one of them is not your injector and is not treated as one.

Several injectors are normal. A monorepo whose cmd/api, cmd/worker and cmd/migrator each wire their own graph has three injectors, and generate, check and doctor process all three in one pass. Which commands do that and which ask you to pick one is in CLI commands.

Two Build calls in the same package is an error, even across two files. That package can only own one generated file, so a second call is genuinely ambiguous rather than a second injector:

servo: multiple servo.Build(...) calls found in the same package example.com/app/cmd/api
(ambiguous — which one owns the generated file?):
  cmd/api/spec.go:16:2
  cmd/api/extra.go:9:2

No Build call anywhere is an error pointing you at the fix: servo: no servo.Build(...) call found — run `servo init` to scaffold a spec file.

Build

func Build(...Marker)

Declares the injector. Every argument must be a Root, Bind, Override, Scoped, Value or Include call written inline — with explicit type arguments for the five generic ones, and with a function name for Include.

That is a real constraint, not a style preference. Servo reads the type arguments out of the type-checker’s instantiation info for the call it found in the syntax tree. A marker value stored in a variable, returned from a helper, or collected into a slice and spread has no instantiation at the Build call site to read:

// Fine.
servo.Build(
	servo.Root[*api.Server](),
)

// Not fine — nothing here for servo to read at the Build call.
markers := []servo.Marker{servo.Root[*api.Server]()}
servo.Build(markers...)

Include is what the second form was reaching for, and it works because servo reads the named function’s body as syntax rather than taking a value the call site has already erased.

Anything else in the argument list is rejected with its position: servo.Build argument is not a marker call, or servo.Build argument must be a Root/Bind/Override/Scoped call with explicit type arguments.

Marker is an empty struct. It carries no data and exists only to give the markers a return type that makes Build’s argument list type-check.

Root

func Root[T any]() Marker

Declares T as a root of the graph. The graph servo builds is exactly the transitive closure of its roots: everything a root depends on, directly or indirectly, is constructed, and every other candidate in your module is left out of the generated file entirely.

  • Several roots are normal. An HTTP server, a background worker and a relay can be three independent roots that share a logger; the shared node is constructed once.
  • Roots are usually pointer types (*api.Server), because that’s what constructors usually return. T must match the provider’s result type exactly — see identity is by type.
  • A root may be an interface. It resolves the same way an interface parameter does, by structural search or an explicit Bind.
  • A root with no provider is a diagnostic, reported against the Root[]() call site.

servo why <type> answers the inverse question — which root pulled a given node in.

Bind

func Bind[I, C any]() Marker

Declares that concrete type C is the implementation to use wherever interface I is requested.

You need it when a parameter’s interface type has more than one implementation in your module, which is otherwise an ambiguity diagnostic — and the diagnostic prints the exact Bind lines to choose from. You don’t need it when there’s exactly one implementation; that auto-binds.

Two behaviours worth knowing:

An explicit bind always wins, even over an interface that would have resolved unambiguously on its own. It is a deliberate override, not only a tie-breaker. servo explain reports the selected provider’s binding as explicit bind.

A bind switches resolution to an exact-type lookup. Once I is bound to C, servo looks for the one function returning exactly C and never runs a structural search for I. If nothing returns C, that’s a missing-provider diagnostic for C, with no candidate suggestions — because suggesting alternatives to an explicit instruction would be second-guessing it.

Two rules are enforced at declaration time:

Rule Error
The second type argument must be a concrete type servo.Bind's second type argument must be a concrete type, not an interface (…) — Bind/Override name the concrete implementation, they don't chain to another interface
The same interface may be bound only once servo.Bind[…, ...] declared twice — first at <pos>

The second rule exists so a second Bind can’t silently win over the first with no diagnostic at all.

Override

func Override[I, C any]() Marker

Declares a test-only replacement for I. Overrides are ignored when generating production code and applied — with priority over Bind — when generating the test variant.

Declaring both for the same interface is the intended pattern, not a collision:

servo.Bind[store.Store, *postgres.DB](),        // production
servo.Override[store.Store, *mockstore.Store](), // tests

One Override anywhere in the spec is what makes servo generate emit a second file, servo_gen_test.go, containing NewTestApp and TestApp. With no overrides declared, only servo_gen.go is written. The generated test type and why it can’t share App are covered in Generated API.

Same rules as Bind: the second type argument must be concrete, and the same interface may be overridden only once. Override is checked against other Overrides only, which is why pairing it with a Bind for the same interface is fine.

An override applies to the whole graph, not to one consumer. There is no way to give one component the mock and another the real implementation; see Limitations.

Scoped

func Scoped[T, I any](...ScopeOption) Marker

Declares that T is a keyed, refcounted, lifecycle-managed instance rather than a singleton, and that I — an interface in your own package — is what consumers depend on to reach it.

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

T must declare a ScopeKey method; I must be a non-empty interface declaring only Acquire, Stats, or both. Everything about what that means — the extractor’s shape, which nodes end up in the scope, when an instance is torn down — is on its own page: Scoped instances.

Linger and Max

func Linger(time.Duration) ScopeOption
func Max(int) ScopeOption

The only two ScopeOptions. Both are read as syntax like everything else here, so both arguments have to be constant expressions30*time.Second and 10_000 are fine, a package variable or a function call is not.

Omitted, they take servo.DefaultLinger (30 seconds) and servo.DefaultMax (10,000). Neither is legal outside a Scoped argument list, and putting one directly in Build says so.

Two Scoped declarations whose ScopeKey methods return the same key type share one registry, and therefore one policy: declaring different Linger or Max values across them is an error, because there is only one of each to set.

Value

func Value[T any]() Marker

Declares that T is supplied by the caller rather than built by a provider — a parsed flag set, a version string injected at link time, a *sql.DB opened by a test harness, a fixed clock.

Everything else in the graph is resolved by finding the one function that produces it. A value that only exists once the process is already running has no such function, and the workaround servo left open — a package-level var in main, read back by a small provider beside it — is the global-lookup pattern this version exists to remove.

servo.Build(
	servo.Value[config.Flags](),
	servo.Root[*api.Server](),
)

Declaring one changes the generated API additively. The injector keeps New(ctx) and gains a Values struct and a NewWith that takes it. What exactly gets emitted, including the test variant’s TestValues/NewTestAppWith, is in Generated API.

Three rules, each of which follows from the marker being a declaration rather than a hint:

A Value beats any provider that also produces T. Declaring one is how you say “this comes from the caller”, which is only meaningful if it wins. It sits above the explicit-Bind step in selection precedence, and no diagnostic is produced for the provider it displaces — the spec file said so.

A declared value nothing depends on is a diagnostic, not a silently unused struct field:

cmd/api/spec.go:13:3: servo: servo.Value[example.com/app/conf.Flags]() is declared, but nothing in
the graph depends on example.com/app/conf.Flags

  A declared value becomes a field on the generated Values struct, so this
  one would be supplied by every caller and read by nobody.

  Two ways out:
    - take it as a constructor parameter somewhere the roots reach:
      func New(v example.com/app/conf.Flags) *Thing
    - delete the servo.Value declaration

The position is the servo.Value[…]() call site — the declaration servo is complaining about, not the type.

Note that this is checked per generated file, so a value used only by a type an Override replaces is reported when the test variant is resolved.

T is matched by type, exactly as a constructor parameter is. The same identity rules apply: T and *T are different keys, and declaring the same T twice is servo.Value[…]() declared twice — first at <pos>.

A supplied value appears everywhere a constructed node does — App.Graph(), servo graph, servo explain, servo why — at level 0, since the app has it before it builds anything, with its binding reported as supplied and its provider as the caller, via NewWith:

$ servo explain --dir cmd/api conf.Flags
example.com/app/conf.Flags
  provider:     the caller, via NewWith (cmd/api/spec.go:13: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

That lifetime line is the limitation worth reading twice: a supplied value is one per app, handed in at NewWith and held for the life of the process. It is not a per-call or per-request value.

Include

func Include(func() []Marker) Marker

Splices another function’s marker list into this Build call. It exists for the module with three binaries whose specs are identical below the transport: the shared markers are written once, and adding a fourth Bind stops being a three-file edit nothing checks.

The shared set is an ordinary function in an ordinary package, carrying the same build tag a spec file carries:

//go:build servoinject

// internal/wiring/wiring.go
package wiring

func Shared() []servo.Marker {
	return []servo.Marker{
		servo.Bind[store.Store, *postgres.DB](),
		servo.Scoped[*chat.Room, chat.Rooms](servo.Linger(time.Minute)),
	}
}

Each spec then carries only what actually differs — here, its own transport’s root:

func wire() {
	servo.Build(
		servo.Include(wiring.Shared),
		servo.Root[*api.Server](),
	)
}

examples/tutorial is the worked case: three injectors sharing one internal/wiring/wiring.go, and three spec files of nineteen lines each where each used to be fifty-three — eleven marker calls per spec, ten of them identical in all three.

The argument names the function; it is never called. It has to be a plain identifier or a package selector resolving to a declared function — not a function literal, not a method value, not a call. Anything else is servo.Include's argument must name a declared func() []servo.Marker — not a literal, a method value, or a call.

The included file must carry the servoinject build tag, for exactly the reason a spec file does: every marker it returns panics if executed, so an untagged file compiles them into your real binary. Both failure modes have their own message — one for a file this build cannot see at all, one for a file it can see that isn’t gated:

cmd/api/spec.go:12:3: servo.Include names example.com/app/wiring.Shared, which is declared in a
file without a `//go:build servoinject` constraint — as written it would compile into the real
binary, where every marker it returns panics

servo-vet flags the calls in that file directly, which is the faster loop; a shared marker set lives away from any spec file, which is where the tag is easiest to forget.

The body must be exactly one return of one slice literal of marker calls. Its contents are read as syntax by the same code that reads Build’s own argument list. Anything servo would have to execute to know the answer — a variable, a conditional, an append — is refused:

wiring/wiring.go:10:1: Shared must be exactly `return []servo.Marker{ ...marker calls... }` —
its body is read as syntax and never run, so anything servo would have to execute to know the
answer is refused

The function may live in another package, which is the whole point: a shared marker set is not part of any one injector. Servo finds the declaration by walking the injector package’s import graph, so the spec file importing it is what makes it reachable.

Includes may nest. An included function may itself Include another. A cycle is a diagnostic naming the path that closed it, not a hang:

wiring/wiring.go:19:3: servo.Include cycle — Shared includes itself, through:
  example.com/app/wiring.Shared
  example.com/app/wiring.More

Local declarations win. Included markers are spliced in where the Include sits, so a Bind or Override written after it in the spec file supersedes an included one for the same interface. That is deliberate and is the only ordering that makes a shared set worth having: a shared default you cannot deviate from in one binary is a shared default you stop using. Two local declarations for the same interface are still the duplicate they always were — servo.Bind[…, ...] declared twice.

The go:generate directive

// servo_generate.go — untagged, and holding only this
package main

//go:generate go tool servo generate

Scaffolded by servo init into its own file, not into the spec file, and worth keeping in that form. Both halves are deliberate:

Untagged, in a separate file. go generate honours build constraints, so a directive inside the //go:build servoinject spec file is invisible to go generate ./... — which then exits 0, prints nothing, and generates nothing.

go tool servo, not go run <module path>. A module that requires servo requires it for the marker package alone, so the generator’s own dependencies are not in that module’s build list and go run github.com/okian/servo/v3/cmd/servo fails on a missing go.sum entry. go get -tool github.com/okian/servo/v3/cmd/servo, once per module, puts the generator in go.mod — which also pins its version, so every developer and every CI runner generates with the same one. That matters because two servo versions can legitimately produce two different (both correct) files, and servo check compares bytes.

Errors from this stage

Everything the spec parser can reject, in one place:

Message Cause
no servo.Build(...) call found No injector in the main module
multiple servo.Build(...) calls found in the same package Two Build calls in one package
spec file is missing a `//go:build servoinject` constraint Untagged spec file
servo.Build argument is not a marker call Something other than a call in the argument list
servo.Build argument must be a Root/Bind/Override/Scoped call with explicit type arguments A marker without inline type arguments
unrecognized servo marker "X" inside Build(...) A servo function that isn’t a marker
servo.Root expects exactly one type argument Root with the wrong arity
servo.Bind/Override expects exactly two type arguments Bind/Override with the wrong arity
second type argument must be a concrete type, not an interface Binding an interface to an interface
servo.Bind[…] declared twice Duplicate bind (or duplicate override) for one interface, both written locally
servo.Value expects exactly one type argument Value with the wrong arity
servo.Value[…]() declared twice One Value per type
servo.Include takes exactly one argument, the name of a func() []servo.Marker Include with the wrong arity
servo.Include's argument must name a declared func() []servo.Marker A literal, a method value, or a call where the function name belongs
servo.Include names …, whose declaration is not in this build The included function is not reachable in this configuration
servo.Include names …, which is declared in a file without a `//go:build servoinject` constraint The included file is untagged
X must be exactly `return []servo.Marker{ ... }` The included function’s body is something servo would have to run
servo.Include cycle — X includes itself, through: Two included sets include each other
servo.Scoped expects exactly two type arguments Scoped with the wrong arity
servo.Scoped's first type argument must be the concrete scoped type An interface where the scoped type belongs
servo.Scoped's first type argument must be a pointer, not X Acquire has to be able to return a zero alongside an error
servo.Scoped's second type argument must be an interface A concrete type where the accessor interface belongs
servo.Scoped's accessor interface … declares no methods any is satisfied by everything, which makes the accessor unusable
servo.Scoped[T, …] declared twice One scoped type, one declaration
servo.Scoped[…, I] declared twice One accessor interface cannot stand for two scoped types
servo.Scoped's arguments must be servo.Linger(...) or servo.Max(...) calls Something else in the option list
servo.X is not a scope option A servo marker that isn’t Linger or Max
servo.Linger/Max is a scope option, not a Build marker An option at the top level of Build
servo.Linger/Max declared twice in the same servo.Scoped One value each per declaration
servo.Linger/Max's argument must be a constant expression Read as syntax, never executed
servo.Linger(…) must not be negative Use servo.Linger(0) for die-with-the-last-holder
servo.Max(N) must be positive A scope that can hold no instances can never hand one out
servo runtime package … is not imported by any file this build configuration can see No spec file exists yet, or every one of them is gated out of this configuration

Diagnostics from the later stages — missing providers, ambiguity, cycles — are on the Diagnostics page.