Context Propagation
Learning objectives
- Explain what
context.Contextcarries: cancellation, deadline, and request-scoped values. - Follow the conventions:
ctxis the first parameter, flows down every call, and is never stored in a struct. - Create derived contexts with
WithCancel,WithTimeout, and usectx.Done()in select loops. - Use context values sparingly and with typed keys.
Prerequisites
Time estimate
3 hours
Concepts
One request, one context
Every request that enters a DX service — HTTP call, RabbitMQ delivery, cron tick — gets a context.Context that accompanies every function call made on its behalf:
func (h *Handler) CreatePolicy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // created by net/http, cancelled if the client disconnects
policy, err := h.svc.Create(ctx, req)
...
}
func (s *Service) Create(ctx context.Context, req CreateRequest) (*Policy, error) {
return s.store.Insert(ctx, toDomain(req)) // keep passing it down
}
When the client disconnects or a timeout fires, the context is cancelled, and everything holding it — database queries, HTTP calls, your loops — can stop promptly instead of wasting work.
The conventions (memorize these)
ctx context.Contextis the first parameter, namedctx.- Pass it down; never store it in a struct. A stored context outlives its request and cancels at the wrong time. (Rare exceptions exist deep in libraries; your code doesn't need them.)
- Never pass
nil— usecontext.Background()at the top level (inmain, tests) andcontext.TODO()as a temporary marker. - Cancellation is advisory: functions must check it. Long loops include a
ctx.Done()case.
Deriving contexts
// Timeout: auto-cancels after 5s — and ALWAYS defer cancel() to release resources
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
row := pool.QueryRow(ctx, query, id) // pgx aborts the query if ctx expires
// Cancel: how main stops background workers on shutdown
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
...
cancel() // worker's <-ctx.Done() fires; it returns
ctx.Err() tells you why: context.Canceled or context.DeadlineExceeded. Wrap it like any error.
The shutdown pattern
This is how every DX background loop terminates — the concrete meaning of "context-cancelled on shutdown" from the standards:
func (d *Dispatcher) Run(ctx context.Context) error {
ticker := time.NewTicker(d.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := d.drainOutbox(ctx); err != nil {
d.log.Warn("outbox drain failed", zap.Error(err))
}
case <-ctx.Done():
return ctx.Err() // graceful exit
}
}
}
Context values — rarely, and with typed keys
context.WithValue attaches request-scoped data. It's for cross-cutting metadata that rides along with a request — request IDs, the authenticated user — not for passing parameters. Use unexported typed keys so packages can't collide:
type ctxKey struct{}
func WithUser(ctx context.Context, u *DxUser) context.Context {
return context.WithValue(ctx, ctxKey{}, u)
}
func UserFrom(ctx context.Context) (*DxUser, bool) {
u, ok := ctx.Value(ctxKey{}).(*DxUser) // comma-ok, as always
return u, ok
}
If a function can't work without the value, make it an explicit parameter instead.
Both uses are live in dx-common-go: the RequestID middleware stores the request ID in the context so the zap logger can stamp every line, and the auth resolver middleware stores the authenticated DxUser so handlers can retrieve the caller with an accessor exactly like UserFrom above. Every repository method in every service takes ctx first and hands it to pgx — which is what makes statement timeouts and client-disconnect cleanup actually work.
Exercises
- Write
slowOp(ctx, d time.Duration)that respects cancellation via select. Call it with a shorterWithTimeoutand confirm you getcontext.DeadlineExceeded(test witherrors.Is). - Retrofit your Module-2 file hasher: workers take
ctx, Ctrl-C (signal.NotifyContext) cancels everything, and the program exits cleanly with a partial summary. - Implement
WithUser/UserFromwith a typed key; demonstrate that another package using astringkey cannot collide with yours. - Find the bug: a struct that stores
ctxfrom its constructor and uses it in a method called minutes later. Explain what goes wrong and fix the API.
Check yourself
- Why is storing a context in a struct wrong?
- What's the difference between
context.Background()andcontext.TODO()? - Why must you
defer cancel()even for a timeout that "will fire anyway"? - What belongs in a context value, and what never does?