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.Notifierstill needs a real NATS connection, even here. It was built in chapter 7 to open its own connection directly rather than going throughbroker.EventPublisher— a deliberate choice at the time, to demonstrate the consuming side of messaging independently — but it meansnotifierisn’t one of the four interfacesOverridetouches. CallingTestApp.Run(ctx)would still try to reach real NATS. The test below never callsRunfor exactly this reason.- Every package’s
Configstill requires its own required environment variables, even the ones whose real values are about to go unused.POSTGRES_DSN,REDIS_ADDR, andNATS_URLare still validated as present —Configisn’t behind an interface, soOverridehas nothing to substitute for it.JWT_SECRETis the one value that actually matters here, sinceauth.Issueris 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 explicitBind. The fix is always the same: add theservo.Bind[...]()the error message already suggests.NewTestAppfails 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.Runhangs or fails to connect —notifierisn’t behind an overridden interface; don’t callRunin a test that has no real NATS available. Test the HTTP surface directly throughapp.server.Handler()instead, as shown above. - A
NewTestApp-based HTTP test gets back an unexpected500instead 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...". APanicReporterpanic raised from inside a request handler (a missing or wrong.EXPECT()on the mock that handler calls) still happens insiderecoverMiddleware’s reach (chapter 10) — so it gets caught, logged, and turned into an ordinary500response, 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 mentioningservotest.PanicReporter— unlike the case above, this is aPanicReporterpanic firing outside any request (most often fromctrl.Finish()inside at.Cleanup, verifying every.EXPECT()was actually called) — nothing wraps it in arecover, so it takes the test binary down with it. The panic message itself names the problem directly —missing call(s) to *mocks.MockOrderCache.Set(...)orUnexpected 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 checkreports drift right after a manual edit toservo_gen.go— expected; the file is markedDO NOT EDITfor exactly this reason. Change the source, not the generated output, and regenerate.
Do’s and don’ts
- Do add
servo.Bindfor 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 inservoinstead of a real ambiguity is not. - Do keep
spec.goas the only file that importsservo(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.goorservo_gen_test.go, ever, even for a “quick” fix — the nextservo generatesilently overwrites it, andservo checkin 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.