Опирается на правила:
GO-6.1…GO-6.5,GO-6.X1…GO-6.X2из Go Style Guide → раздел 6. Контекст.
Важно знать
context.Context— всегда первый аргумент функции, имя параметра —ctx.- Контекст не хранится в поле структуры — он живёт на стеке вызова.
- Передавай
ctxво все IO-вызовы: SQL (pgx), HTTP-клиент, Kafka, Redis.- Таймаут задаётся в out-adapter через
context.WithTimeout, значение — из конфига (envconfig), не константой.- В контексте хранятся только cross-cutting данные:
trace-id,tenant-id,auth-principal.- Долгие циклы обязаны проверять
<-ctx.Done(), иначе горутина не завершится при отмене.context.Background()внутри handler/usecase — антипаттерн: теряется цепочка отмены.gocritic/staticcheckловят часть нарушений; семантику проверяет ревью поGO-6.*.
context.Context — механизм распространения отмены, дедлайна и запросных значений через стек вызовов. В Go нет thread-local-хранилища, поэтому контекст передаётся явно. Дисциплина передачи — не стилистика, а корректность: IO-вызов без контекста нельзя отменить при истечении HTTP-таймаута или shutdown сервиса.
Первый аргумент, имя ctx
GO-6.1: context.Context — первый аргумент каждой функции, имя параметра — всегда ctx.
func (r *orderRepository) FindByID(ctx context.Context, id string) (*order.Order, error) {
row := r.db.QueryRow(ctx, `SELECT id, status, customer_id FROM orders WHERE id = $1`, id)
// ...
}
func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) (string, error) {
o, err := h.repo.FindByID(ctx, cmd.CustomerID)
// ...
}
context.Context первым — требование стандартной библиотеки и Go idiom. Линтер revive (правило context-as-argument) предупреждает при нарушении порядка.
Передача во все IO-вызовы
GO-6.2: ctx передаётся в каждый IO-вызов.
func (r *productRepository) Save(ctx context.Context, p *product.Product) error {
_, err := r.db.Exec(ctx,
`INSERT INTO products (id, name, price_minor) VALUES ($1, $2, $3)`,
p.ID(), p.Name(), p.PriceMinor(),
)
return err
}
func (c *sberPaymentClient) Charge(ctx context.Context, req ChargeRequest) (*ChargeResult, error) {
httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/charge", body)
resp, err := c.http.Do(httpReq)
// ...
}
pgx принимает context.Context в каждом методе (Exec, Query, QueryRow). HTTP-клиент — через http.NewRequestWithContext. Kafka-producer (kafka-go) — через WriteMessages(ctx, ...). Redis (go-redis/v9) — через Get(ctx, key). Без контекста запрос продолжается даже после отмены вышестоящего — ресурс занят, горутина не освобождается.
Таймаут в out-adapter
GO-6.3: таймаут оборачивается в out-adapter, значение берётся из конфига.
type CustomerAdapterConfig struct {
Timeout time.Duration `envconfig:"CUSTOMER_ADAPTER_TIMEOUT" default:"2s"`
}
type customerAdapter struct {
http *http.Client
cfg CustomerAdapterConfig
baseURL string
}
func (a *customerAdapter) GetByID(ctx context.Context, id string) (*customer.Customer, error) {
tctx, cancel := context.WithTimeout(ctx, a.cfg.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(tctx, http.MethodGet, a.baseURL+"/customers/"+id, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := a.http.Do(req)
if err != nil {
return nil, fmt.Errorf("get customer %s: %w", id, err)
}
defer resp.Body.Close()
// ...
}
Таймаут — в конфиге (envconfig), а не const timeout = 2 * time.Second. Так значение меняется без ребилда и отражается в манифесте деплоя. defer cancel() сразу после WithTimeout — обязательно, иначе ресурсы таймера не освобождаются.
Если вышестоящий контекст уже несёт более короткий дедлайн (ctx от HTTP-запроса с дедлайном 500ms, а конфиг говорит 2s), WithTimeout выберет ближайший — это корректное поведение.
Значения в контексте — только cross-cutting
GO-6.4: в контекст кладутся только данные, пересекающие все слои горизонтально.
type contextKey int
const (
ctxKeyTraceID contextKey = iota
ctxKeyTenantID
ctxKeyAuthPrincipal
)
func WithTraceID(ctx context.Context, traceID string) context.Context {
return context.WithValue(ctx, ctxKeyTraceID, traceID)
}
func TraceIDFrom(ctx context.Context) (string, bool) {
v, ok := ctx.Value(ctxKeyTraceID).(string)
return v, ok
}
Ключ — приватный тип (contextKey int), не строка: исключает коллизии между пакетами. Геттер возвращает (T, bool) — caller явно обрабатывает отсутствие.
Что не хранится в контексте: идентификатор заказа, параметры фильтрации, опции сортировки — всё, что можно передать явным аргументом. Контекст с бизнес-данными — скрытый аргумент: его не видно в сигнатуре, сложно тестировать, легко потерять при создании нового контекста.
func (h *ListOrdersHandler) Handle(ctx context.Context, query ListOrdersQuery) ([]*order.Order, error) {
customerID := query.CustomerID
status := query.Status
return h.repo.FindByCustomer(ctx, customerID, status)
}
customerID и status — аргументы query-объекта, не значения контекста.
Проверка ctx.Done() в долгих операциях
GO-6.5: цикл, выполняющий много итераций или ожидающий событий, проверяет ctx.Done().
func (p *orderProcessor) ProcessBatch(ctx context.Context, orders []order.Order) error {
for _, o := range orders {
select {
case <-ctx.Done():
return fmt.Errorf("batch processing cancelled: %w", ctx.Err())
default:
}
if err := p.process(ctx, o); err != nil {
return fmt.Errorf("process order %s: %w", o.ID(), err)
}
}
return nil
}
select { case <-ctx.Done(): ... default: } — неблокирующая проверка: если контекст не отменён, управление идёт в default и итерация продолжается. Горутина, игнорирующая ctx.Done() в цикле, остаётся работать после отмены запроса — утечка ресурсов при shutdown.
Для consumer-горутин Kafka, Redis Pub/Sub и аналогичных блокирующих вызовов:
func (c *orderConsumer) Run(ctx context.Context) error {
for {
msg, err := c.reader.FetchMessage(ctx)
if err != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("fetch message: %w", err)
}
if err := c.handle(ctx, msg); err != nil {
c.logger.Error("handle message", slog.String("error", err.Error()))
}
if err := c.reader.CommitMessages(ctx, msg); err != nil {
return fmt.Errorf("commit: %w", err)
}
}
}
FetchMessage(ctx) — заблокируется до сообщения или отмены контекста. При ctx.Err() != nil — нормальный завершение цикла без возврата ошибки.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
ctx context.Context не первым аргументом | GO-6.1 | первый параметр, имя ctx |
r.db.Exec("INSERT ...", args...) без ctx | GO-6.2 | r.db.Exec(ctx, "INSERT ...", args...) |
const timeout = 5 * time.Second в коде адаптера | GO-6.3 | таймаут из envconfig, context.WithTimeout(ctx, cfg.Timeout) |
context.WithValue(ctx, "orderID", id) | GO-6.4 | явный аргумент функции; ключ-строка → коллизия |
context.WithValue(ctx, "userID", id) с ключом-строкой | GO-6.4 | приватный тип-ключ (type contextKey int) |
for _, item := range items { process(item) } без ctx.Done() | GO-6.5 | select { case <-ctx.Done(): return ... default: } |
ctx := context.Background() внутри handler | GO-6.X1 | использовать ctx из аргумента функции |
type Service struct { ctx context.Context } | GO-6.X2 | контекст на стеке вызова, не в поле структуры |
defer cancel() после ветки if err != nil | GO-6.3 | defer cancel() сразу после WithTimeout |
Куда дальше
- Конкурентность — горутины,
errgroup,semaphoreиctx-отмена при shutdown. - Управляющие структуры — guard clause,
deferдля ресурсов, раннийreturn err. - golangci-lint — конфиг
.golangci.yml, линтерыrevive,staticcheck,gocritic. - Observability → Context propagation — как
trace-idиslogиспользуютcontext.Contextдля сквозного трассирования.