Опирается на правила: GO-6.1GO-6.5, GO-6.X1GO-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...) без ctxGO-6.2r.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.5select { case <-ctx.Done(): return ... default: }
ctx := context.Background() внутри handlerGO-6.X1использовать ctx из аргумента функции
type Service struct { ctx context.Context }GO-6.X2контекст на стеке вызова, не в поле структуры
defer cancel() после ветки if err != nilGO-6.3defer 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 для сквозного трассирования.