Capstone: design a small concurrent service — then count the cost
Sixteen lessons built one tool at a time; this one assembles them into a single working design and then does the thing the whole series has been training you for — it adds up the bill. We will sketch a real service (a bounded, cancellable URL-fetch worker pool that exposes results over HTTP), watch interfaces, errors-as-values, goroutines, channels, context, packages, and tests fall into place because the five questions force them there, and then close with an unflinching account of what Go's bargain actually cost us. The point is not the service. The point is that you can now read any design as a ledger of complexity paid and convenience bought.
%w/errors.Is (L07), goroutines & the scheduler (L08), channels & ownership transfer (L09), select/context cancellation (L10), mutexes vs channels (L11), package boundaries (L12), and table-driven tests (L15).New capability: assemble those into one coherent concurrent design, justify each piece by the engineering problem it solves, and give the honest costs of the language's bets the way the C++ track gives its honest scope.
if err != nil verbosity, a thin type system, no inheritance or enums, nil-interface traps, and where generics still don't reach.1 · The service, and the requirement that makes it real
The toy is this: a service that, given a batch of URLs, fetches them concurrently and returns each one's status and byte-length. Trivial — until you add the requirement every production version of this has: it must be bounded and cancellable. Bounded, because a batch of 10,000 URLs must not open 10,000 sockets at once and exhaust file descriptors. Cancellable, because the HTTP client who asked for the batch may disconnect, or a deadline may pass, and continuing to fetch is wasted money and a goroutine leak. That single requirement — do work concurrently, but with a ceiling and a kill switch — is what turns a for loop into a design, and it is exactly the shape of most real backend work: crawlers, batch enrichers, fan-out RPC aggregators.
Read the diagram with question 2 (who owns the data, how does it move?) already in mind: a jobs channel carries URLs into a fixed pool of W workers, a results channel carries answers back, and a single context threads through everything as the cancellation signal. Nothing is shared and locked; everything is passed. That is the CSP shape from Lesson 09, and the bound — W workers, not N — is the entire reason the pattern exists.
2 · The five questions build the design
Every earlier lesson now arrives because a question demands it. This is the payoff of the dependency chain: you don't reach for features, the problem reaches for them.
| Question | What it forces here | Lesson |
|---|---|---|
| 1 · What can I leave out? | No job queue library, no actor framework, no DI container. The standard library's channels and net/http are the whole runtime. A dependency is only added if its absence costs more than its presence — here, nothing earns its way in. | L01, L13 |
| 2 · Who owns the data, how does it move? | A URL is owned by exactly one worker at a time — ownership transfers when it is sent on the jobs channel. Results move back the same way. No shared mutable state means no mutex on the hot path. | L02, L09 |
| 3 · How does it fail, is failure in the signature? | A fetch can fail; the result type carries an error field, not a panic. The aggregate function returns ([]Result, error) so the caller sees the unhappy path in the type. Per-URL errors are data; a cancelled context is a returned error. | L07 |
| 4 · Behavior or concretion? | The fetcher depends on a one-method interface, not on *http.Client. That makes the worker pool testable without a network and swappable without a rewrite — "accept interfaces." | L05, L13 |
| 5 · What did it cost, who pays? | Each worker goroutine costs ~2 KB of initial stack; W of them, not N, caps memory and FDs. The channels add scheduler hops. The GC will collect the result slices. All bounded, all in §4's ledger. | L03, L08, L11 |
Notice question 4 is what makes question 3 testable and question 5 affordable: because the worker depends on a behavior (Fetcher), a test can inject a fake that returns canned errors and never touches the network, so you can verify the failure paths from question 3 deterministically. The questions are not a checklist; they interlock.
3 · The assembled design
First the package boundary (question 1, Lesson 12): the import graph must be acyclic and the public surface tiny. fetch is a library package that knows nothing about HTTP serving; main wires it to an HTTP handler. The library never imports the server — dependencies point inward.
The behavior the pool depends on — one method, the canonical small interface (Lesson 05). The concrete *http.Client satisfies a tiny adapter; a test satisfies it with a struct.
package fetch
import "context"
// Fetcher is the only thing the pool depends on. Accept interfaces.
type Fetcher interface {
Fetch(ctx context.Context, url string) (status int, n int64, err error)
}
type Result struct {
URL string
Status int
Bytes int64
Err error // failure is a value in the result, not a panic (L07)
}
The pool itself. Read it as the CSP diagram made literal: jobs in, results out, context as the kill switch, a sync.WaitGroup to know when every worker has drained so we can close results exactly once (Lesson 11 — a WaitGroup coordinates lifetime; channels move the data).
func FetchAll(ctx context.Context, f Fetcher, urls []string, workers int) ([]Result, error) {
jobs := make(chan string)
results := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for url := range jobs { // ranges until jobs is closed
status, n, err := f.Fetch(ctx, url)
select {
case results <- Result{url, status, n, err}:
case <-ctx.Done(): // don't block forever if cancelled
return
}
}
}()
}
// Feeder: hands out work, stops early on cancellation.
go func() {
defer close(jobs)
for _, u := range urls {
select {
case jobs <- u:
case <-ctx.Done():
return
}
}
}()
// Closer: results is closed once, after all workers are done.
go func() { wg.Wait(); close(results) }()
out := make([]Result, 0, len(urls))
for r := range results {
out = append(out, r)
}
if err := ctx.Err(); err != nil {
return out, err // cancellation is in the signature (L07, L10)
}
return out, nil
}
Three details earn their place. The feeder goroutine selects between sending a job and ctx.Done() so cancellation stops dispatch immediately instead of after all N sends — without it you'd leak the feeder if no worker ever reads again. Workers also select on send so a cancelled collector can't deadlock them. And close(results) happens in exactly one place, gated by wg.Wait(); closing a channel twice, or from a sender that doesn't own it, panics — so ownership of the close is assigned to a single goroutine (Lesson 09). This is the whole reason CSP discipline matters: the bugs it prevents are the ones that only appear under load.
The HTTP edge, in main, ties a request's lifetime to the work via r.Context() — when the client disconnects, every goroutine above unwinds for free:
func handler(f fetch.Fetcher) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var urls []string
if err := json.NewDecoder(r.Body).Decode(&urls); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// r.Context() is cancelled when the client disconnects (L10)
results, err := fetch.FetchAll(r.Context(), f, urls, 16)
if err != nil {
http.Error(w, err.Error(), http.StatusRequestTimeout)
return
}
json.NewEncoder(w).Encode(results)
}
}
And the test that question 4 made possible — no network, deterministic, table-driven (Lesson 15). Because the pool accepts a Fetcher interface, the fake is ten lines and the failure path is exercised on purpose:
type fakeFetcher struct{ fail map[string]error }
func (ff fakeFetcher) Fetch(_ context.Context, url string) (int, int64, error) {
if err := ff.fail[url]; err != nil {
return 0, 0, err
}
return 200, 42, nil
}
func TestFetchAll(t *testing.T) {
ff := fakeFetcher{fail: map[string]error{"bad": errTimeout}}
got, err := FetchAll(context.Background(), ff, []string{"ok", "bad"}, 4)
if err != nil {
t.Fatalf("unexpected: %v", err)
}
if len(got) != 2 {
t.Fatalf("want 2 results, got %d", len(got))
}
// run `go test -race` — the pool must be clean under the detector (L11)
}
go test -race is clean. The race detector instruments memory accesses and flags any unsynchronized read/write pair; it is the cheapest correctness tool Go gives you (Lesson 11). The design above is race-free by construction — no two goroutines touch the same memory — but the detector is what proves it, and what catches the refactor six months from now that quietly adds a shared counter.4 · The honest accounting — what the bargain cost
The service works, reads cleanly, and a stranger could change it. Now the part the series exists for: the bill. Every line below is a real cost of a real Go decision, stated the way the C++ track states its honest scope. None of these is a reason not to use Go; all of them are reasons to know what you bought.
- Cheap, readable concurrency. 16 workers cost ~32 KB of initial stack total; the same shape with OS threads (~1 MB each) is 16 MB and you'd never spawn thousands. The pool reads like the diagram.
- Cancellation for free. One
contextthreaded through unwinds the whole tree on client disconnect or deadline — no manual bookkeeping (L10). - Failure you can see. Every fail mode is a returned value in a signature; there is no hidden exception path through this code (L07).
- Testable without the world. A one-method interface let the test run with zero network and full determinism (L05).
- GC tail latency. Collecting result slices is a concurrent mark-sweep with sub-ms stop-the-world pauses — but under a large heap the p99.9 still shows GC, and you cannot turn it off, only tune
GOGC(L03). - Verbosity. Count the
if err != nilblocks above. Explicit-and-repetitive was chosen over clever-and-hidden; you pay in lines and screen space every day (L07). - A thin type system.
Result.Statusis anint; nothing stops-1. No enums, no sum types — "make illegal states unrepresentable" is awkward here (L14). - Generics don't reach.
FetchAllis hard-wired tostring→Result; a generic worker pool is expressible since 1.18 but the constraints get ugly fast, so most code stays concrete (L14).
Two costs deserve their own line because they bite when you least expect it. The nil-interface trap (L05, L07): if a worker returned a *MyError that is nil through a function typed to return error, the returned interface is non-nil — a (type, nil-data) pair — and err != nil is true even though "nothing went wrong." It is the single most-stepped-on rake in the language, and a concurrent service that wraps errors across goroutines is exactly where it hides. No inheritance (L06): there is no base Worker class to extend; if you want a retrying fetcher you wrap the Fetcher interface (a decorator), you don't subclass it. That is usually better — but it is genuinely a different muscle, and engineers arriving from Java reach for a hierarchy that Go simply will not give them.
W) was paid to buy safety and velocity. The verbosity, the thin types, and the nil-interface rake are the other side of that same coin — the price of making the cheap, readable, cancellable version the default one.Checkpoint exercise
Fetcher interface (a decorator struct holding the inner Fetcher), not modify the pool; question 3 says decide whether the final failure is a Result.Err value or a returned error; question 5 says count the cost — retries multiply the goroutine's live time and the load on the target. Then run go test -race. If you reached for a class hierarchy or a queue library, ask question 1 again: did its absence really cost more than its presence?Where this points next
This is the last lesson, so the handoff is back to the whole map. Return to the series index and re-read the spine of five movements now that you have assembled it once end to end — the joints (02→04→05→06 building abstraction without inheritance; 08→09→10→11 building concurrency on top of values) read differently when you've felt them snap together in one design. The real continuation, though, is not another Go lesson: it is the next codebase you read in any language, where you will now reflexively ask who owns this data, how does it fail, what could be left out, and who pays — which was the point of learning Go all along.
Fetcher, L05), and count the cost (2 KB stacks × W workers, a bounded GC pause, L03/L08). Then comes the honest ledger that makes this trade-off literacy and not advocacy: you bought cheap readable concurrency, free cancellation, visible failure, and network-free tests — and you paid in GC tail latency, if err != nil verbosity, a thin type system, no enums or inheritance, and the nil-interface rake. The transferable habit is the ledger itself: read every design as complexity paid and convenience bought, name both columns, and you reason better about every system you touch thereafter.Interview prompts
- Why a worker pool of
Wgoroutines instead of one goroutine per URL? (§1, §4 — to bound resources:Ngoroutines meansNopen sockets/FDs andN×2 KB of stack; a fixedWcaps memory and concurrency while channels still feed allNjobs through.) - How does cancellation propagate through the design, and what breaks without it? (§3 — one
contextthreads fromr.Context()through feeder and workers; eachselects onctx.Done(). Without it the feeder leaks if collectors stop reading, and workers keep fetching after the client is gone — a goroutine leak.) - Why does the pool depend on a
Fetcherinterface rather than*http.Client? (§2, §3 — "accept interfaces": a one-method behavior makes the pool testable with a fake (no network, deterministic failure paths) and swappable without a rewrite.) - Who is allowed to close the
resultschannel, and why does it matter? (§3 — exactly one goroutine, gated bywg.Wait(); closing twice or closing from a non-owning sender panics, so close-ownership is assigned to a single closer goroutine — CSP discipline from L09.) - Give the honest GC cost of this service. (§4 — result slices are collected by a concurrent tri-color mark-sweep with sub-ms STW pauses, but the GC cannot be disabled, only tuned via
GOGC; under a large live heap the p99.9 latency still shows it — a bounded but unavoidable cost, L03.) - Explain the nil-interface trap and why a concurrent service is where it bites. (§4 — a typed nil pointer (
*MyError) returned through anerrorinterface becomes a non-nil (type, nil) pair, soerr != nilis unexpectedly true; wrapping errors across goroutines is exactly where this hides, L05/L07.) - You need retries; how do you add them without touching the pool? (Checkpoint, §4 — wrap the
Fetcherinterface in a decorator struct that retries internally (composition, not inheritance, L06); the pool is unchanged because it depends on the behavior, and you re-rungo test -race.)