13. Wiring with servo

Nine packages exist now, each with its own constructor, and nothing connecting them yet — no main.go that constructs a logger-shaped thing then a database then a cache then a service then a server, checking errors and unwinding partial construction at every step. That’s the file this chapter doesn’t write by hand.

Declare the graph

Create cmd/orders/spec.go, gated by the servoinject build tag so it never compiles into the real binary:

//go:build servoinject

package main

import (
	"example.com/servoorders/internal/transport/api"
	"example.com/servoorders/internal/broker"
	"example.com/servoorders/internal/cache"
	"example.com/servoorders/internal/broker/natsbroker"
	"example.com/servoorders/internal/broker/notifier"
	"example.com/servoorders/internal/repository/postgres"
	"example.com/servoorders/internal/cache/redis"
	"example.com/servoorders/internal/repository"
	"github.com/okian/servo/v3/servo"
)

func wire() {
	servo.Build(
		servo.Root[*api.Server](),
		servo.Root[*notifier.Notifier](),

		servo.Bind[repository.OrderRepository, *postgres.Store](),
		servo.Bind[repository.UserRepository, *postgres.Store](),
		servo.Bind[cache.OrderCache, *redis.Cache](),
		servo.Bind[broker.EventPublisher, *natsbroker.Publisher](),
	)
}

Two roots: api.Server (everything the HTTP layer needs, transitively) and notifier.Notifier (which nothing else depends on, so without declaring it a root, servo would never know to build it at all — an unreferenced constructor is just dead code as far as the graph is concerned).

A diagnostic worth triggering on purpose

Before adding the four servo.Bind lines above, try generating without them — just the two roots. (This section also uses mocks/servo_adapters.go, written later in this same chapter — if you’re following in order and haven’t created it yet, you’ll see two implementers instead of three below, which makes exactly the same point.)

$ go run github.com/okian/servo/v3/cmd/servo generate --dir .
example.com/servoorders/cmd/orders: servo: 4 diagnostic(s):

.../service/service.go:28:6: servo: no provider for example.com/servoorders/internal/repository.OrderRepository
  needed by *example.com/servoorders/internal/service.OrderService  .../service/service.go:28:6
  needed by *example.com/servoorders/internal/transport/api.Server            .../transport/api/server.go:49:6
  root                                                      .../cmd/orders/spec.go:23:3

  3 types implement example.com/servoorders/internal/repository.OrderRepository — add one of:
      servo.Bind[example.com/servoorders/internal/repository.OrderRepository, *example.com/servoorders/internal/mocks.MockOrderRepository]()      .../mocks/repository_mock.go:34:6
      servo.Bind[example.com/servoorders/internal/repository.OrderRepository, *example.com/servoorders/internal/mocks.OrderRepositoryForServo]()      .../mocks/servo_adapters.go:24:6
      servo.Bind[example.com/servoorders/internal/repository.OrderRepository, *example.com/servoorders/internal/repository/postgres.Store]()      .../repository/postgres/postgres.go:41:6

(Three more diagnostics follow, identical in shape, for UserRepository, OrderCache, and EventPublisher.) This is worth actually seeing rather than taking on faith, and it’s worth noticing there are three candidates, not two: mocks.MockOrderRepository (gomock’s own generated mock, from chapter 8) and mocks.OrderRepositoryForServo (the wrapper this chapter adds further down, for servo.Override) both structurally satisfy repository.OrderRepository exactly as well as postgres.Store does. servo has no way to know which one you meant “for real,” and it’s right not to guess. The four Bind lines above aren’t optional the way they might look in a module with only one implementation of each interface — once even one mock exists anywhere in the module, an explicit Bind is what makes generation succeed at all.

Generate, and read what came back

With the binds in place:

$ go run github.com/okian/servo/v3/cmd/servo generate --dir .

No output — success is silent, matching every other servo command. cmd/orders/servo_gen.go now exists, starting with a resolved-graph comment that’s worth reading end to end before the code below it.

This is the committed file from examples/tutorial, which is the finished service, so a few entries come from chapters you haven’t reached yet — observability (chapter 15), resilience (chapter 16), and the session scope at the bottom (chapter 14). Generate it yourself at this point and you’ll get the same shape with those omitted:

//	[L1] *example.com/servoorders/internal/config.Env
//	      deps: none
//	      capabilities: none | binding: sole implementation | config/config.go:42:6
//	[L2] *example.com/servoorders/internal/session.Config
//	      deps: *example.com/servoorders/internal/config.Env
//	      capabilities: none | binding: sole candidate | session/session.go:66:6
//	[L2] *example.com/servoorders/internal/observability.Config
//	      deps: *example.com/servoorders/internal/config.Env
//	      capabilities: none | binding: sole candidate | observability/logging.go:21:6
//	[L3] *example.com/servoorders/internal/observability.Logger
//	      deps: *example.com/servoorders/internal/observability.Config
//	      capabilities: none | binding: sole candidate | observability/logging.go:35:6
//	[L2] *example.com/servoorders/internal/transport/api.Config
//	      deps: *example.com/servoorders/internal/config.Env
//	      capabilities: none | binding: sole candidate | transport/api/server.go:45:6
//	[L2] *example.com/servoorders/internal/repository/postgres.Config
//	      deps: *example.com/servoorders/internal/config.Env
//	      capabilities: none | binding: sole candidate | repository/postgres/postgres.go:37:6
//	[L3] *example.com/servoorders/internal/repository/postgres.Store
//	      deps: *example.com/servoorders/internal/repository/postgres.Config
//	      capabilities: Initializer, Finalizer, Healther | binding: explicit bind | repository/postgres/postgres.go:41:6
//	[L2] *example.com/servoorders/internal/cache/redis.Config
//	      deps: *example.com/servoorders/internal/config.Env
//	      capabilities: none | binding: sole candidate | cache/redis/redis.go:36:6
//	[L3] *example.com/servoorders/internal/cache/redis.Cache
//	      deps: *example.com/servoorders/internal/cache/redis.Config
//	      capabilities: Initializer, Finalizer, Healther | binding: sole candidate | cache/redis/redis.go:40:6
//	[L4] *example.com/servoorders/internal/resilience.CircuitBreakerCache
//	      deps: *example.com/servoorders/internal/cache/redis.Cache
//	      capabilities: none | binding: explicit bind | resilience/breaker.go:38:6
//	[L2] *example.com/servoorders/internal/broker/natsbroker.Config
//	      deps: *example.com/servoorders/internal/config.Env
//	      capabilities: none | binding: sole candidate | broker/natsbroker/natsbroker.go:35:6
//	[L3] *example.com/servoorders/internal/broker/natsbroker.Publisher
//	      deps: *example.com/servoorders/internal/broker/natsbroker.Config
//	      capabilities: Initializer, Finalizer, Healther | binding: explicit bind | broker/natsbroker/natsbroker.go:46:6
//	[L5] *example.com/servoorders/internal/service.OrderService
//	      deps: *example.com/servoorders/internal/repository/postgres.Store, *example.com/servoorders/internal/resilience.CircuitBreakerCache, *example.com/servoorders/internal/broker/natsbroker.Publisher, *example.com/servoorders/internal/observability.Logger
//	      capabilities: none | binding: sole candidate | service/service.go:28:6
//	[L2] *example.com/servoorders/internal/auth.Config
//	      deps: *example.com/servoorders/internal/config.Env
//	      capabilities: none | binding: sole candidate | auth/auth.go:35:6
//	[L3] *example.com/servoorders/internal/auth.Issuer
//	      deps: *example.com/servoorders/internal/auth.Config
//	      capabilities: none | binding: sole candidate | auth/auth.go:39:6
//	[L4] *example.com/servoorders/internal/service.AuthService
//	      deps: *example.com/servoorders/internal/repository/postgres.Store, *example.com/servoorders/internal/auth.Issuer
//	      capabilities: none | binding: sole candidate | service/auth_service.go:18:6
//	[L1] *example.com/servoorders/internal/observability.Metrics
//	      deps: none
//	      capabilities: none | binding: sole candidate | observability/metrics.go:18:6
//	[L3] *example.com/servoorders/internal/observability.Tracer
//	      deps: *example.com/servoorders/internal/observability.Config
//	      capabilities: Finalizer | binding: sole candidate | observability/tracing.go:29:6
//	[L2] *example.com/servoorders/internal/resilience.Config
//	      deps: *example.com/servoorders/internal/config.Env
//	      capabilities: none | binding: sole candidate | resilience/ratelimit.go:29:6
//	[L3] *example.com/servoorders/internal/resilience.RateLimiter
//	      deps: *example.com/servoorders/internal/resilience.Config, *example.com/servoorders/internal/observability.Metrics
//	      capabilities: none | binding: sole candidate | resilience/ratelimit.go:33:6
//	[L6] *example.com/servoorders/internal/transport/api.Server
//	      deps: *example.com/servoorders/internal/transport/api.Config, *example.com/servoorders/internal/service.OrderService, *example.com/servoorders/internal/service.AuthService, *example.com/servoorders/internal/auth.Issuer, *example.com/servoorders/internal/observability.Metrics, *example.com/servoorders/internal/observability.Tracer, *example.com/servoorders/internal/resilience.RateLimiter, example.com/servoorders/internal/session.Sessions, *example.com/servoorders/internal/observability.Logger
//	      capabilities: Runner, Finalizer | binding: sole candidate | transport/api/server.go:49:6
//	[L2] *example.com/servoorders/internal/broker/notifier.Config
//	      deps: *example.com/servoorders/internal/config.Env
//	      capabilities: none | binding: sole candidate | broker/notifier/notifier.go:28:6
//	[L4] *example.com/servoorders/internal/broker/notifier.Notifier
//	      deps: *example.com/servoorders/internal/broker/notifier.Config, *example.com/servoorders/internal/observability.Logger
//	      capabilities: Runner | binding: sole candidate | broker/notifier/notifier.go:37:6
// scope example.com/servoorders/internal/session.UserID
//	linger: 5m0s | max: 50000
//	accessor: example.com/servoorders/internal/session.Sessions -> *example.com/servoorders/internal/session.Session
//	[S1] *example.com/servoorders/internal/session.Session
//	      capabilities: Initializer, Flusher, Finalizer
//	borrows: *example.com/servoorders/internal/session.Config, *example.com/servoorders/internal/observability.Logger

Capabilities, side by side

Nine chapters built these components one at a time; here’s every capability every one of them ended up with, in one place, and why each is what it is:

Type Capabilities Why
config.Env none A snapshot of the environment, read once
each package’s Config none Pure data, parsed and validated at construction
observability.Logger none Built from its own config; everything that logs depends on it
postgres.Store Initializer, Finalizer, Healther Connects, disconnects, and can report a real health check
redis.Cache Initializer, Finalizer, Healther Same shape as Store — connect/disconnect/health
natsbroker.Publisher Initializer, Finalizer, Healther Same shape again
service.OrderService none Pure orchestration logic, nothing to start or stop
auth.Issuer none Pure JWT/hashing logic
service.AuthService none Pure orchestration logic
api.Server Runner, Finalizer Serves until told to stop (chapter 10’s Run bug lives here); Stop closes the listener
notifier.Notifier Runner (no Finalizer) Its own cleanup (conn.Drain()) happens via defer inside Run itself when ctx cancels, not a separate Stop

Every one of these is detected structurally — types.Implements, checked at generation time. Nothing in any of these nine packages imports servo except the spec file itself. notifier having no Stop isn’t a gap: it genuinely doesn’t need one, and servo doesn’t require every component to implement every capability, or even any of them.

New, Run, and Shutdown

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

	env := config.NewEnv()
	a.env = env

	postgresConfig, err := postgres.NewConfig(env)
	if err != nil {
		return nil, err
	}
	a.postgresConfig = postgresConfig

	store, err := postgres.New(postgresConfig)
	if err != nil {
		return nil, err
	}
	a.store = store
	// ... one NewConfig per package, then cache, publisher, logger,
	// orderService, issuer, authService, server, notifier ...

	{
		var timingMu sync.Mutex
		g, gctx := errgroup.WithContext(ctx)
		g.Go(func() error {
			start := time.Now()
			err := a.store.Init(gctx)
			timingMu.Lock()
			a.startupReport.Nodes = append(a.startupReport.Nodes, servo.StartupNode{Type: "*example.com/servoorders/internal/repository/postgres.Store", Duration: time.Since(start)})
			timingMu.Unlock()
			return err
		})
		// ... cache.Init, publisher.Init, run concurrently in the same errgroup ...
		if err := g.Wait(); err != nil {
			report := a.Shutdown(ctx)
			return nil, errors.Join(err, report)
		}
	}
	return a, nil
}

Construction happens in dependency order — config before anything that needs it, store before orderService. Init calls for the three Initializers run concurrently, in one errgroup, since none of them depend on each other (all three only depend on config, which is already built) — servo doesn’t serialize work that has no reason to be serial. If any Init fails, New calls Shutdown itself before returning, so a failed startup never leaves the components that did succeed connected with nothing tracking them.

Run launches every Runner and waits for all of them — this is exactly the errgroup shown in chapter 10’s postmortem, so its behavior should already feel familiar rather than new. Shutdown runs in reverse order — api.Server first (stop accepting new work before tearing down what it depends on), then the three infrastructure Finalizers — each one exactly once, via sync.Once, so a second Shutdown call (or two concurrent ones) is safe.

Check it, graph it, ask it questions

$ go run github.com/okian/servo/v3/cmd/servo check --dir .

Silent, same as generate — check re-resolves and re-emits in memory, diffs against what’s committed, and only prints something if they disagree.

$ go run github.com/okian/servo/v3/cmd/servo graph --dir ./cmd/orders --format=mermaid
graph BT
  n0["*example.com/servoorders/internal/config.Env"]:::level1
  n1["*example.com/servoorders/internal/session.Config"]:::level2
  n2["*example.com/servoorders/internal/observability.Config"]:::level2
  n3["*example.com/servoorders/internal/observability.Logger"]:::level3
  n4["*example.com/servoorders/internal/transport/api.Config"]:::level2
  n5["*example.com/servoorders/internal/repository/postgres.Config"]:::level2
  n6["*example.com/servoorders/internal/repository/postgres.Store"]:::level3
  n7["*example.com/servoorders/internal/cache/redis.Config"]:::level2
  n8["*example.com/servoorders/internal/cache/redis.Cache"]:::level3
  n9["*example.com/servoorders/internal/resilience.CircuitBreakerCache"]:::level4
  n10["*example.com/servoorders/internal/broker/natsbroker.Config"]:::level2
  n11["*example.com/servoorders/internal/broker/natsbroker.Publisher"]:::level3
  n12["*example.com/servoorders/internal/service.OrderService"]:::level5
  n13["*example.com/servoorders/internal/auth.Config"]:::level2
  n14["*example.com/servoorders/internal/auth.Issuer"]:::level3
  n15["*example.com/servoorders/internal/service.AuthService"]:::level4
  n16["*example.com/servoorders/internal/observability.Metrics"]:::level1
  n17["*example.com/servoorders/internal/observability.Tracer"]:::level3
  n18["*example.com/servoorders/internal/resilience.Config"]:::level2
  n19["*example.com/servoorders/internal/resilience.RateLimiter"]:::level3
  n20["*example.com/servoorders/internal/transport/api.Server"]:::level6
  n21["*example.com/servoorders/internal/broker/notifier.Config"]:::level2
  n22["*example.com/servoorders/internal/broker/notifier.Notifier"]:::level4
  subgraph scope0["scope example.com/servoorders/internal/session.UserID — linger 5m0s, max 50000"]
    k0["example.com/servoorders/internal/session.UserID"]:::scopekey
    n23["*example.com/servoorders/internal/session.Session"]:::level1
  end
  n1 --> n0
  n2 --> n0
  n3 --> n2
  n4 --> n0
  n5 --> n0
  n6 --> n5
  n7 --> n0
  n8 --> n7
  n9 --> n8
  n10 --> n0
  n11 --> n10
  n12 --> n6
  n12 --> n9
  n12 --> n11
  n12 --> n3
  n13 --> n0
  n14 --> n13
  n15 --> n6
  n15 --> n14
  n17 --> n2
  n18 --> n0
  n19 --> n18
  n19 --> n16
  n20 --> n4
  n20 --> n12
  n20 --> n15
  n20 --> n14
  n20 --> n16
  n20 --> n17
  n20 --> n19
  n20 --> k0
  n20 --> n3
  n21 --> n0
  n22 --> n21
  n22 --> n3
  n23 --> k0
  n23 --> n1
  n23 --> n3
  classDef level1 fill:#bfdbfe;
  classDef level2 fill:#93c5fd;
  classDef level3 fill:#60a5fa;
  classDef level4 fill:#3b82f6;
  classDef level5 fill:#2563eb;
  classDef level6 fill:#1d4ed8;
  classDef scopekey fill:#fef9c3,stroke-dasharray: 4 2;

That’s the complete, unedited output — the classDef lines are what give each level its own shade when rendered. Here it is rendered, with the full import paths shortened to just the type name so it’s actually readable at a glance:

graph BT
  n0["config.Env"]:::level1
  n1["session.Config"]:::level2
  n2["observability.Config"]:::level2
  n3["observability.Logger"]:::level3
  n4["api.Config"]:::level2
  n5["postgres.Config"]:::level2
  n6["postgres.Store"]:::level3
  n7["redis.Config"]:::level2
  n8["redis.Cache"]:::level3
  n9["resilience.CircuitBreakerCache"]:::level4
  n10["natsbroker.Config"]:::level2
  n11["natsbroker.Publisher"]:::level3
  n12["service.OrderService"]:::level5
  n13["auth.Config"]:::level2
  n14["auth.Issuer"]:::level3
  n15["service.AuthService"]:::level4
  n16["observability.Metrics"]:::level1
  n17["observability.Tracer"]:::level3
  n18["resilience.Config"]:::level2
  n19["resilience.RateLimiter"]:::level3
  n20["api.Server"]:::level6
  n21["notifier.Config"]:::level2
  n22["notifier.Notifier"]:::level4
  subgraph scope0["scope example.com/servoorders/internal/session.UserID — linger 5m0s, max 50000"]
    k0["session.UserID"]:::scopekey
    n23["session.Session"]:::level1
  end
  n1 --> n0
  n2 --> n0
  n3 --> n2
  n4 --> n0
  n5 --> n0
  n6 --> n5
  n7 --> n0
  n8 --> n7
  n9 --> n8
  n10 --> n0
  n11 --> n10
  n12 --> n6
  n12 --> n9
  n12 --> n11
  n12 --> n3
  n13 --> n0
  n14 --> n13
  n15 --> n6
  n15 --> n14
  n17 --> n2
  n18 --> n0
  n19 --> n18
  n19 --> n16
  n20 --> n4
  n20 --> n12
  n20 --> n15
  n20 --> n14
  n20 --> n16
  n20 --> n17
  n20 --> n19
  n20 --> k0
  n20 --> n3
  n21 --> n0
  n22 --> n21
  n22 --> n3
  n23 --> k0
  n23 --> n1
  n23 --> n3
  classDef level1 fill:#bfdbfe;
  classDef level2 fill:#93c5fd;
  classDef level3 fill:#60a5fa;
  classDef level4 fill:#3b82f6;
  classDef level5 fill:#2563eb;
  classDef level6 fill:#1d4ed8;
  classDef scopekey fill:#fef9c3,stroke-dasharray: 4 2;

And a targeted question — why does postgres.Store exist at all, from the graph’s perspective:

$ go run github.com/okian/servo/v3/cmd/servo why --dir ./cmd/orders postgres.Store
root  *example.com/servoorders/internal/transport/api.Server
  -> *example.com/servoorders/internal/service.OrderService
  -> *example.com/servoorders/internal/repository/postgres.Store

Testing the whole thing without any of it running

Everything up to this point still needs real Postgres, Redis, and NATS to actually construct. servo.Override changes that, for tests specifically — add it to spec.go, alongside the binds:

servo.Override[repository.OrderRepository, *mocks.OrderRepositoryForServo](),
servo.Override[repository.UserRepository, *mocks.UserRepositoryForServo](),
servo.Override[cache.OrderCache, *mocks.OrderCacheForServo](),
servo.Override[broker.EventPublisher, *mocks.EventPublisherForServo](),

The gomock mocks from chapter 8 can’t be used directly here: NewMockOrderRepository(ctrl *gomock.Controller) needs a *gomock.Controller, which itself needs something implementing gomock.TestReporter — and there’s no *testing.T reachable from inside a generated graph. servotest.PanicReporter (from servo’s own servotest package) supplies one without pulling gomock into servo’s own servotest at all — the same pattern servo’s examples/mocking/gomock already establishes. Create mocks/servo_adapters.go:

package mocks

import (
	"go.uber.org/mock/gomock"

	"github.com/okian/servo/v3/servotest"
)

type OrderRepositoryForServo struct {
	*MockOrderRepository
	Finish func()
}

func NewOrderRepositoryForServo() *OrderRepositoryForServo {
	ctrl := gomock.NewController(servotest.PanicReporter{})
	return &OrderRepositoryForServo{MockOrderRepository: NewMockOrderRepository(ctrl), Finish: ctrl.Finish}
}

// UserRepositoryForServo, OrderCacheForServo, and EventPublisherForServo
// follow the exact same three-line shape, one per remaining interface.

Regenerating now also produces cmd/orders/servo_gen_test.go — a NewTestApp with the same shape as New, except the four overridden dependencies are the zero-arg *ForServo wrappers instead of postgres.New/redis.New/natsbroker.New.

Two things about NewTestApp are easy to assume and both wrong — worth stating plainly rather than letting you find out by confusion:

  • notifier.Notifier still needs a real NATS connection, even here. It was built in chapter 7 to open its own connection directly rather than going through broker.EventPublisher — a deliberate choice at the time, to demonstrate the consuming side of messaging independently — but it means notifier isn’t one of the four interfaces Override touches. Calling TestApp.Run(ctx) would still try to reach real NATS. The test below never calls Run for exactly this reason.
  • Every package’s Config still requires its own required environment variables, even the ones whose real values are about to go unused. POSTGRES_DSN, REDIS_ADDR, and NATS_URL are still validated as present — Config isn’t behind an interface, so Override has nothing to substitute for it. JWT_SECRET is the one value that actually matters here, since auth.Issuer is real and unmocked.

With both of those understood, the test itself is straightforward — construct, expect, hit the HTTP handler directly:

func TestFullAPIFlowWithMockedInfrastructure(t *testing.T) {
	t.Setenv("POSTGRES_DSN", "unused-in-this-test")
	t.Setenv("REDIS_ADDR", "unused-in-this-test")
	t.Setenv("NATS_URL", "unused-in-this-test")
	t.Setenv("JWT_SECRET", "test-secret")

	app, err := NewTestApp(context.Background())
	if err != nil {
		t.Fatalf("NewTestApp: %v", err)
	}
	t.Cleanup(func() {
		app.userRepositoryForServo.Finish()
		app.orderRepositoryForServo.Finish()
		app.orderCacheForServo.Finish()
		app.eventPublisherForServo.Finish()
	})

	hash, _ := auth.HashPassword("password123")
	testUser := &domain.User{ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Username: "alice", PasswordHash: hash}
	app.userRepositoryForServo.EXPECT().GetByUsername(gomock.Any(), "alice").Return(testUser, nil)
	app.orderCacheForServo.EXPECT().Set(gomock.Any(), gomock.Any()).Return(nil)
	app.orderRepositoryForServo.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil)
	app.eventPublisherForServo.EXPECT().PublishOrderPlaced(gomock.Any(), gomock.Any()).Return(nil)

	ts := httptest.NewServer(app.server.Handler())
	defer ts.Close()

	// ... POST /auth/login, then POST /orders with the returned token ...

	if r := app.Shutdown(context.Background()); !r.Clean() {
		t.Errorf("Shutdown not clean: %v", r)
	}
}

app.server, app.userRepositoryForServo, and friends are all unexported fields — reachable here only because this test file lives in package main, the same package servo_gen_test.go does, exactly like servo’s own examples/basic/cmd/basic/app_test.go reaches into its generated App the same way.

$ go test ./cmd/orders/... -v
=== RUN   TestFullAPIFlowWithMockedInfrastructure
--- PASS: TestFullAPIFlowWithMockedInfrastructure (0.12s)
PASS
ok  	example.com/servoorders/cmd/orders	0.521s

Every layer from chapter 5 through chapter 10, wired exactly the way main.go wires it for real, running in well under a second, with zero containers.

Diagnostics

  • servo: N diagnostic(s) listing two implementers of the same interface — as demonstrated above, this is what happens the moment a mock and a real implementation coexist in one module with no explicit Bind. The fix is always the same: add the servo.Bind[...]() the error message already suggests.
  • NewTestApp fails with a “required environment variable” error — see the config nuance above; every required field still needs a value in a test, even an unused placeholder string for the three that won’t actually be dialed.
  • A test calling TestApp.Run hangs or fails to connectnotifier isn’t behind an overridden interface; don’t call Run in a test that has no real NATS available. Test the HTTP surface directly through app.server.Handler() instead, as shown above.
  • A NewTestApp-based HTTP test gets back an unexpected 500 instead of an obvious crash — check the test’s logged output for a line like "msg":"api: panic recovered","panic":"Unexpected call to *mocks.MockOrderRepository.Create(...) because: there are no expected calls...". A PanicReporter panic raised from inside a request handler (a missing or wrong .EXPECT() on the mock that handler calls) still happens inside recoverMiddleware’s reach (chapter 10) — so it gets caught, logged, and turned into an ordinary 500 response, exactly the way a real, unrelated panic would. The test doesn’t crash; it just fails with a confusing status code. The fix is almost always a missing or misconfigured .EXPECT(), not a bug in the handler.
  • A NewTestApp-based test crashes the whole process with a full Go stack trace mentioning servotest.PanicReporter — unlike the case above, this is a PanicReporter panic firing outside any request (most often from ctrl.Finish() inside a t.Cleanup, verifying every .EXPECT() was actually called) — nothing wraps it in a recover, so it takes the test binary down with it. The panic message itself names the problem directly — missing call(s) to *mocks.MockOrderCache.Set(...) or Unexpected call to ... because: there are no expected calls... — and the receiver type in that message (*mocks.MockOrderCache, *mocks. MockOrderRepository, etc.) is exactly which mock to go fix the expectations on.
  • servo check reports drift right after a manual edit to servo_gen.go — expected; the file is marked DO NOT EDIT for exactly this reason. Change the source, not the generated output, and regenerate.

Do’s and don’ts

  • Do add servo.Bind for an interface as soon as a second structural implementer appears anywhere in the module — including a mock. Waiting for the diagnostic to tell you is fine; treating it as a bug in servo instead of a real ambiguity is not.
  • Do keep spec.go as the only file that imports servo (besides generated output). If a second file starts importing it, that’s usually a sign some wiring logic escaped the spec file.
  • Don’t hand-edit servo_gen.go or servo_gen_test.go, ever, even for a “quick” fix — the next servo generate silently overwrites it, and servo check in CI exists specifically to catch a hand-edited file that’s drifted from what the real source would produce.
  • Don’t add a root for something nothing else needs unless you actually want it constructed and run — an orphaned root is a real, if harmless, way to accidentally start something (a listener, a connection) that was only meant to exist as a library.

Next

Chapter 14: Scoped instances — the one thing in this graph that shouldn’t be built once and held forever.