Skip to Content
PatternsAPI composition

API Composition

Deep-dive по паттерну composition’а: как собрать ответ клиенту из данных нескольких сервисов без shared database и без cross-service JOIN. Reference по HTTP — ../conventions/http-api. Эта страница — про архитектурный выбор, обработку partial failure, кэширование, N+1 и когда вместо composition надо использовать projection.

Содержание

Проблема

UI запрашивает GET /v1/places/{id}/details и ждёт ответ со всеми деталями места:

  • Имя места, адрес, координаты — принадлежат catalog-сервису.
  • Средний рейтинг, количество отзывов — review-сервис.
  • Фотографии — media-сервис.
  • Ближайшие события в этом месте — community-сервис.

Каждое из этих данных владеется своим сервисом. Shared database запрещена (см. ../architecture-overview.md §Data management). Cross-database JOIN невозможен: у каждого сервиса своя БД. Клиент ждёт один ответ, а не четыре параллельных HTTP-запроса со сторедой координации.

API Composition — паттерн, когда один сервис выступает композитором: принимает запрос, параллельно опрашивает несколько downstream-сервисов, собирает ответ и отдаёт клиенту.

Варианты

API Composer в Gateway / BFF

Gateway (Cilium Gateway API + api-gateway) или отдельный BFF-сервис делает composition’ы. Плюс — downstream-сервисы остаются простыми, каждый знает только свою область. Минус — Gateway растёт, BFF становится отдельным сервисом с своей логикой.

Не используем. Edge у нас — роутинг (Cilium Gateway API) + TLS + JWT (api-gateway как forward-auth proxy), не бизнес-логика. BFF явно отказались (см. ../architecture-overview.md §External API).

API Composer в одном из сервисов

Сервис-владелец основного aggregate’а (catalog для «деталей места») внутри своего handler’а опрашивает остальные. HTTP-клиент выходит в review-, media-, community-сервисы параллельно, сливает ответы.

Это наш выбор. Пояснение — §Наш выбор ниже.

Client-side composition

Клиент (mobile/web) делает 4 параллельных HTTP-запроса, сам собирает. Плюс — backend простой, нет composer’а. Минус для mobile — 4 round-trip’а по плохой сети, суммарная latency значительно выше.

Не используем для /v1/*. Backend-API отдаёт композированный ответ, чтобы mobile не делал multi-request.

Materialized view через проекцию (CQRS projection)

Review-сервис подписан на review.* события и проецирует данные в read-model-таблицу, владельцем которой является catalog-сервис (или отдельный read-сервис). Catalog читает эту таблицу без HTTP-вызова.

Плюсы — мгновенный ответ, нет downstream dependency. Минусы — eventual consistency (проекция отстаёт от источника), лишние таблицы, нужен дизайн projection-слоя.

Используем только когда composition не справляется. §Когда НЕ использовать composition ниже.

Наш выбор

API Composer в сервисе-владельце основного aggregate’а.

Причины:

  • Нет отдельного composer’а / BFF, меньше движущихся частей.
  • Нет projection-таблиц, нет отставания от источника, consistency синхронная в пределах одного HTTP-ответа.
  • Понятно, кто owner endpoint’а — тот же сервис, кто owner основного aggregate’а.

Пример: «детали места» — в place-service (когда он появится); «лента отзывов с авторами» — в review-service (автор дотягивается из user-service).

Архитектура

Общий flow: composer-сервис принимает HTTP-запрос от клиента, параллельно через errgroup опрашивает downstream-сервисы (каждого владельца данных — по отдельности), собирает ответ и отдаёт клиенту одним payload’ом.

Ключевое: всё в один round-trip для клиента, параллельность внутри composer’а, per-downstream timeout. Детали — §Реализация, §Timeouts, §Partial failure.

Реализация

type PlaceService struct { repo PlaceRepo reviewClient ReviewClient // http-клиент в review-service mediaClient MediaClient // http-клиент в media-service logger *slog.Logger } func (s *PlaceService) GetDetails(ctx context.Context, id int64) (*PlaceDetails, error) { g, gctx := errgroup.WithContext(ctx) var ( place *Place rating *Rating media []Photo ) g.Go(func() error { p, err := s.repo.GetPlace(gctx, id) if err != nil { return fmt.Errorf("place repo: %w", err) } place = p return nil }) g.Go(func() error { r, err := s.reviewClient.GetRating(gctx, id) if err != nil { return fmt.Errorf("review client: %w", err) } rating = r return nil }) g.Go(func() error { m, err := s.mediaClient.GetPhotos(gctx, id) if err != nil { // Partial-failure policy: фото опциональны. s.logger.WarnContext(gctx, "media client failed", "place_id", id, "err", err) metrics.CompositionPartialFailures.WithLabelValues("place_details", "media").Inc() media = nil return nil // не ломаем ответ } media = m return nil }) if err := g.Wait(); err != nil { return nil, err } return &PlaceDetails{ Place: place, Rating: rating, Photos: media, }, nil }

Ключевое:

  • errgroup.WithContext. При первой error’е из goroutine контекст cancel’ится — остальные downstream-вызовы прервутся, не продолжат работу впустую.
  • Параллельность. Все вызовы стартуют одновременно, суммарная latency ≈ max(downstream latencies), а не sum.
  • Partial-failure policy внутри goroutine. Media — опциональны, поэтому error ловится и проглатывается (лог + метрика). Critical downstream (place, rating) — error пробрасывается и ломает ответ.

Timeouts

Каждый downstream-вызов обязан иметь свой timeout. Context propagation + HTTP-клиент с http.Client{Timeout: ...}:

type reviewHTTPClient struct { base string http *http.Client } func NewReviewClient(base string) ReviewClient { return &reviewHTTPClient{ base: base, http: &http.Client{ Timeout: 2 * time.Second, // per-request timeout Transport: otelhttp.NewTransport(http.DefaultTransport), }, } } func (c *reviewHTTPClient) GetRating(ctx context.Context, placeID int64) (*Rating, error) { ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/internal/places/%d/rating", c.base, placeID), nil) if err != nil { return nil, fmt.Errorf("build req: %w", err) } // ... }

Правила:

  • Per-downstream timeout — жёсткий, не больше, чем нужно для 99% нормального ответа. Для internal-endpoint’а в кластере — 1–3 секунды.
  • Aggregate timeout (общий для composition’а) — меньше, чем сумма downstream timeout’ов, но достаточный для max: обычно 3–5 секунд. Выставляется HTTP-handler’ом на входе:
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() details, err := s.svc.GetDetails(ctx, id)
  • Никогда без timeout. http.Client{} без Timeout — один «зависший» downstream повесит весь composition навсегда. Семафор handler’ов в сервере забьётся, упадёт latency всех endpoint’ов.

Распределение timeout между downstream

Правило распределения бюджета зависит от того, как идут вызовы — параллельно, последовательно или смешанно.

  • Параллельные downstream. Если handler имеет deadline T и N вызовов идут через errgroup.WithContext одновременно — каждому выделяется T - network_buffer, а не T / N. Параллельность значит, что max среди всех и есть общее время ожидания, не сумма.
  • Последовательные downstream. Если N вызовов идут по очереди, каждому выделяется (T - network_buffer) / N. Это честный дележ на критическом пути.
  • Смешанный режим. Сначала вычисли critical path — самую длинную последовательную цепочку вызовов. Между шагами на ней разделяем бюджет (T - buffer) / steps_on_critical_path. Параллельные ветви, отстреливаемые из узла критического пути, получают max доступный бюджет на ветвь.

network_buffer = 50–100 мс на сериализацию, TLS-handshake (если connection pool пустой — cold-start на первом hit’е после idle connection таймится transport’ом HTTP-клиента и обходится дорого), DNS-резолюшн, возврат ответа клиенту. Без запаса handler может вернуть ошибку клиенту не из-за downstream, а из-за собственной логики сериализации уже над ctx.DeadlineExceeded.

Если perDownstream после вычета buffer получается меньше минимума, на котором downstream может физически ответить (rule of thumb: ~200 мс для cross-service в кластере с TLS), — возвращай service.ErrUpstreamTimeout сразу, не порти pool connection’ов заведомо безнадёжным запросом:

const minDownstreamBudget = 200 * time.Millisecond perDownstream := time.Until(dl) - networkBuffer if perDownstream < minDownstreamBudget { return nil, service.ErrUpstreamTimeout }

Пример на Go для параллельного случая:

func (s *PlaceService) GetDetails(ctx context.Context, id int64) (*PlaceDetails, error) { // Handler-timeout уже выставлен chi-middleware: ctx.Deadline()=3s. const networkBuffer = 100 * time.Millisecond dl, ok := ctx.Deadline() if !ok { return nil, fmt.Errorf("missing deadline in context") } perDownstream := time.Until(dl) - networkBuffer if perDownstream <= 0 { return nil, context.DeadlineExceeded } g, gctx := errgroup.WithContext(ctx) var ( place *Place rating *Rating photos []Photo ) g.Go(func() error { cctx, cancel := context.WithTimeout(gctx, perDownstream) defer cancel() p, err := s.repo.GetPlace(cctx, id) place = p return err }) g.Go(func() error { cctx, cancel := context.WithTimeout(gctx, perDownstream) defer cancel() r, err := s.reviewClient.GetRating(cctx, id) rating = r return err }) g.Go(func() error { cctx, cancel := context.WithTimeout(gctx, perDownstream) defer cancel() ph, err := s.mediaClient.GetPhotos(cctx, id) photos = ph return err }) if err := g.Wait(); err != nil { return nil, err } return &PlaceDetails{Place: place, Rating: rating, Photos: photos}, nil }

Для последовательного варианта:

func (s *PlaceService) GetChain(ctx context.Context, id int64) (*Result, error) { const networkBuffer = 100 * time.Millisecond const steps = 3 dl, _ := ctx.Deadline() perStep := (time.Until(dl) - networkBuffer) / steps if perStep <= 0 { return nil, context.DeadlineExceeded } step := func(parent context.Context, fn func(context.Context) error) error { cctx, cancel := context.WithTimeout(parent, perStep) defer cancel() return fn(cctx) } // ... шаги выполняются последовательно через step(ctx, ...) return nil, nil }

Не клади defer cancel() в цикл — на N итерациях это N defer’ов на стеке, GC увидит их только при возврате. Используй явный cancel() сразу после использования или оборачивай шаг в отдельную функцию.

Ограничение concurrency

errgroup без ограничения порождает по одной goroutine на каждый downstream-вызов. Для «3 downstream на один запрос» это нормально. Но как только composition идёт по списку (batch из 50 мест × 3 downstream), внутри одного HTTP-запроса легко получается 150+ одновременных исходящих соединений — connection pool downstream’ов забивается, Postgres-пул composer’а (если хоть один вызов идёт в свою БД) исчерпан, 95-й перцентиль всех endpoint’ов деградирует.

Правило: любой composition, который может породить >10 одновременных исходящих операций, обязан иметь concurrency-limit. Инструмент — errgroup.Group.SetLimit (стандартный, работает с g.Go):

g, gctx := errgroup.WithContext(ctx) g.SetLimit(8) // максимум 8 одновременных downstream-вызовов for _, id := range ids { id := id g.Go(func() error { cctx, cancel := context.WithTimeout(gctx, perDownstream) defer cancel() r, err := s.reviewClient.GetRating(cctx, id) if err != nil { return err } mu.Lock() ratings[id] = r mu.Unlock() return nil }) } if err := g.Wait(); err != nil { return nil, err }

Выбор лимита:

ИсточникЗначениеПримечание
Per-downstream HTTP keep-alive pool10–20Transport.MaxConnsPerHost HTTP-клиента composer’а. Лимит composition’а не должен превышать этот размер, иначе goroutines встанут на pool wait.
Per-downstream service throughputзависит от SLO downstream’аНе выжимай весь запас downstream на один batch — оставь запас на штатный трафик.
Composer’s own DB poolPOOL_MAX сервисаЕсли composition включает вызов в свою БД, concurrency composition’а + concurrency других endpoint’ов не должны превышать POOL_MAX.

Дефолт для batch-сценариев — SetLimit(8). Для критичных high-QPS composer’ов — обоснованное значение, согласованное с владельцами downstream (не больше 20% их SLO-budget’а на latency).

Альтернатива SetLimitявный семафор на chan struct{}. Используется, когда нужно ограничивать между несколькими errgroup’ами одного handler’а или когда errgroup не применим (например, контекст пробрасывается в worker pool, живущий дольше одного запроса):

sem := make(chan struct{}, 8) for _, id := range ids { id := id select { case sem <- struct{}{}: case <-ctx.Done(): return ctx.Err() } go func() { defer func() { <-sem }() // ... }() }

Но для обычной per-request composition’а errgroup.SetLimit достаточно и проще — меньше шансов забыть drain при early return.

Метрики для наблюдения за давлением на concurrency:

composition_concurrency_in_use{composer, endpoint} — gauge, текущее число активных goroutine внутри одного composition-запроса (обновляется семафорной обёрткой). composition_concurrency_wait_seconds{composer, endpoint} — histogram, сколько goroutine ждала разрешения из семафора.

Alert: `histogram_quantile(0.99, composition_concurrency_wait_seconds)

0.1` за 5 мин → ticket. Если goroutines ждут освобождения слота больше 100ms — лимит тесный или downstream деградирует, и стоит посмотреть оба параметра.

Partial failure

Для каждого downstream классифицируй: critical или optional.

Critical downstream

Без него ответ не имеет смысла. Пример: place (без имени места ответ пустой). Error → пробрасываем наверх, handler отвечает 500 или 404.

Optional downstream

Без него ответ деградирует, но UI всё равно может что-то показать. Пример: photos (UI покажет placeholder), rating (UI покажет «нет оценок»).

Логика в composition:

g.Go(func() error { r, err := s.reviewClient.GetRating(gctx, id) if err != nil { // Optional: не ломаем ответ, но логируем и считаем. s.logger.WarnContext(gctx, "rating downstream failed", "place_id", id, "err", err) metrics.CompositionPartialFailures.WithLabelValues("place_details", "review").Inc() rating = &Rating{} // pseudo-zero, UI поймёт return nil } rating = r return nil })

Правила:

  • Никогда не проглатывай error молча. Всегда log (WARN) + метрика. Без видимости partial failures невозможно понять, что downstream деградирует.
  • Документируй policy в коде. Комментарий // Optional downstream: failure = empty result рядом с catch’ем. Ревьюер должен видеть осознанное решение, а не «забыли обработать».
  • UI-контракт. Поля для optional downstream в ответе должны быть omitempty / nullable. Клиент должен уметь показать пустое.

Не-очевидный случай: timeout на critical

Critical downstream отвечает с timeout’ом. Возвращать 500 — плохо, это внешний эффект нашей инфраструктуры. Возвращать 503 с Retry-After — лучше, клиент повторит через пару секунд.

if errors.Is(err, context.DeadlineExceeded) { return nil, service.ErrUpstreamTimeout // → 503 в handler mapping }

Контракт partial-failure в response

Partial failure для optional downstream не должен быть скрытым от клиента — иначе UI не отличит «данных нет» от «сервис лёг». Контракт ответа композера всегда содержит явный блок partial:

{ "data": { "id": 777, "author_id": 42, "text": "Отличное место", "rating": 5, "author_profile": null, "media": [], "reactions_summary": null }, "partial": { "missing": ["author_profile", "reactions_summary"], "reason": { "author_profile": "timeout", "reactions_summary": "unavailable" } } }

Правила контракта:

  • Mandatory поля — без них composer не может собрать осмысленный ответ. Если mandatory недоступен, возвращаем 503 Service Unavailable (или 504 при timeout’е), блок data не отправляется. Пример mandatory для GET /reviews/{id}: id, author_id, text, rating.
  • Optional поля — без них response остаётся валидным, но деградированным. Если fetch упал/таймаут — поле в data выставляется в null / пустой массив ([]), имя поля попадает в partial.missing, partial.reason[field] описывает причину (timeout, unavailable, breaker_open, 5xx, 4xx).
  • HTTP status200 если все mandatory доставлены (даже если optional отсутствуют). 206 Partial Content не используем — этот статус исторически про byte-range-responses, его использование для partial-data сбивает с толку клиентов и middleware (кэши, proxy). Partial-сигнал — только в теле, через partial block.
  • Клиент UI при рендеринге обязан проверить partial.missing и показать плейсхолдеры вместо отсутствующих полей (аватар по-умолчанию вместо фото автора, «—» вместо счётчика реакций и т.п.). Это часть контракта со стороны клиента, документируется в API-reference endpoint’а.
  • Cache-aside: если partial.missing != [], ответ не кэшируется в Redis. Хранить в кэше можно только полные ответы — иначе клиенты продолжат видеть деградированные данные после восстановления downstream. Тот же принцип — для browser- и CDN-кэшей: отдаём Cache-Control: no-store на partial ответах.

Go-структура контракта

package api import "encoding/json" // ReviewResponse — композитный ответ GET /v1/reviews/{id}. type ReviewResponse struct { Data ReviewData `json:"data"` Partial Partial `json:"partial"` } type ReviewData struct { // Mandatory ID int64 `json:"id"` AuthorID int64 `json:"author_id"` Text string `json:"text"` Rating int `json:"rating"` // Optional — omitempty, но присутствуют всегда с null/[] AuthorProfile *AuthorProfile `json:"author_profile"` Media []Photo `json:"media"` ReactionsSummary *ReactionsSummary `json:"reactions_summary"` } type Partial struct { Missing []string `json:"missing"` Reason map[string]string `json:"reason"` } // MarshalJSON гарантирует, что Missing — всегда массив (не null), // а Reason — всегда объект (для стабильного контракта). func (p Partial) MarshalJSON() ([]byte, error) { type alias Partial a := alias(p) if a.Missing == nil { a.Missing = []string{} } if a.Reason == nil { a.Reason = map[string]string{} } return json.Marshal(a) }

Пример mapping для GET /reviews/{id}

ПолеТипКатегория
idint64mandatory
author_idint64mandatory
textstringmandatory
ratingintmandatory
author_profileAuthorProfileoptional (из user-service)
media[]Photooptional (из media-service)
reactions_summaryReactionsSummaryoptional (из reactions-агрегатора)

Композер при обработке каждого optional’а ловит error на его fetch’е, выставляет соответствующее поле в nil/empty, append’ит имя поля в Partial.Missing и заносит Partial.Reason[field] исходя из classifyError(err) (таймаут → "timeout", breaker → "breaker_open" и т.п.).

Если mandatory-поле упало — возвращаем error из service, handler мапит его в 503/504 (см. §Partial failure / timeout на critical). Никакого partial.mandatory_missing не существует — mandatory либо есть, либо ответа нет.

Caching

Composition = несколько downstream-вызовов = latency легко растёт до сотен мс. Частый приём — кэширование композитного ответа в Redis на короткое окно.

func (s *PlaceService) GetDetails(ctx context.Context, id int64) (*PlaceDetails, error) { cacheKey := fmt.Sprintf("place:%d:details:v1", id) if cached, err := s.cache.Get(ctx, cacheKey); err == nil { metrics.CompositionCacheHits.WithLabelValues("place_details").Inc() return cached, nil } metrics.CompositionCacheMisses.WithLabelValues("place_details").Inc() details, err := s.composeDetails(ctx, id) if err != nil { return nil, err } _ = s.cache.Set(ctx, cacheKey, details, 60*time.Second) return details, nil }

Правила:

  • Короткий TTL (30–300 секунд). Длинный TTL = застарелые данные, пользователь видит отзыв за 10 минут до и после редактирования.
  • Версия в ключе (v1). При изменении формата ответа — инкрементируй версию, старые кэш-ключи естественно истекут.
  • Invalidation через события. Review-сервис публикует review. updated → composer-сервис подписан, при получении DEL place:<id>: details:v1. Это правильнее, чем ждать TTL.
  • Cache stampede. Если TTL истёк и 1000 пользователей одновременно запросили тот же place, все 1000 пойдут в downstream. Паттерн single-flight (golang.org/x/sync/singleflight) — один composition на всех.

Circuit breaker

Downstream деградирует (например, review-service p99 latency выросла) → composition ждёт timeout per request → handler’ы накапливаются → pool соединений забивается → HTTP-сервер начинает отвечать 503 на всё.

Защита — circuit breaker на каждого downstream-клиента (см. ../architecture-overview.md §Reliability):

type reviewHTTPClient struct { base string http *http.Client breaker *gobreaker.CircuitBreaker } func (c *reviewHTTPClient) GetRating(ctx context.Context, placeID int64) (*Rating, error) { res, err := c.breaker.Execute(func() (any, error) { return c.doGet(ctx, placeID) }) if err != nil { if errors.Is(err, gobreaker.ErrOpenState) { return nil, service.ErrUpstreamUnavailable } return nil, err } return res.(*Rating), nil }

Что происходит при open breaker:

  • Execute сразу возвращает ErrOpenState, не делая HTTP-вызов.
  • Composer ловит ErrUpstreamUnavailable, применяет partial-failure policy (для optional — возвращает empty, для critical — 503).
  • HTTP-сервер быстро отдаёт ответ вместо зависания → pool соединений не забивается → остальные endpoint’ы живут.

Настройки per-downstream:

  • MaxRequests (в half-open) — обычно 3–5.
  • Interval (reset window) — 30s.
  • Timeout (open state duration) — 60s.
  • ReadyToTripconsecutiveFailures > 5.

Breaker per-pod: каждая реплика composer’а ведёт свой счётчик. Shared-state breaker через Redis — только для платных внешних провайдеров (FCM, SMS-gateway), где «открыть» важно для всего пула реплик одновременно.

N+1 problem

Плохо — composition в цикле по списку:

// ПЛОХО: N+1 func (s *PlaceService) GetList(ctx context.Context, ids []int64) ([]*PlaceDetails, error) { out := make([]*PlaceDetails, 0, len(ids)) for _, id := range ids { d, err := s.GetDetails(ctx, id) // каждый раз 3 HTTP if err != nil { return nil, err } out = append(out, d) } return out, nil }

Для списка из 20 мест — 60 HTTP-вызовов. Latency квадратично растёт.

Хорошо — batch-endpoint’ы на downstream:

func (s *PlaceService) GetList(ctx context.Context, ids []int64) ([]*PlaceDetails, error) { g, gctx := errgroup.WithContext(ctx) var ( places []*Place ratings map[int64]*Rating photos map[int64][]Photo ) g.Go(func() error { p, err := s.repo.GetPlacesBatch(gctx, ids) // один SQL places = p return err }) g.Go(func() error { r, err := s.reviewClient.GetRatingsBatch(gctx, ids) // один HTTP ratings = r return err }) g.Go(func() error { ph, err := s.mediaClient.GetPhotosBatch(gctx, ids) // один HTTP photos = ph return err }) if err := g.Wait(); err != nil { return nil, err } // Объединяем map'ы по id. out := make([]*PlaceDetails, 0, len(places)) for _, p := range places { out = append(out, &PlaceDetails{Place: p, Rating: ratings[p.ID], Photos: photos[p.ID]}) } return out, nil }

Правило: если downstream используется в list-endpoint’е, batch- вариант обязателен. Если downstream не предоставляет batch API — это архитектурный bug downstream-сервиса, открывай тикет его владельцам. Workaround в виде цикла — временно, и только если размер списка < 10.

Testing

Unit — мокаем downstream-клиенты

type fakeReviewClient struct { ratings map[int64]*Rating err error } func (c *fakeReviewClient) GetRating(ctx context.Context, id int64) (*Rating, error) { if c.err != nil { return nil, c.err } return c.ratings[id], nil } func TestGetDetails_Composition(t *testing.T) { tests := []struct { name string reviewErr error wantErr bool }{ {"happy path", nil, false}, {"optional downstream fails", errors.New("boom"), false}, // photos optional {"critical downstream fails", errPlaceFailure, true}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() svc := service.NewPlaceService( fakeRepo, &fakeReviewClient{ratings: map[int64]*Rating{42: {Avg: 4.5}}}, &fakeMediaClient{err: tc.reviewErr}, ) _, err := svc.GetDetails(context.Background(), 42) if (err != nil) != tc.wantErr { t.Fatalf("err: got %v, wantErr %v", err, tc.wantErr) } }) } }

Integration — docker-compose с реальными сервисами

Для composer’а tests на уровне сервиса: поднимаем review-service и media-service через docker-compose, composer конфигурируется указать на них, тест делает реальный HTTP-request.

Детали testcontainers — ../conventions/testing.

Monitoring

composition_duration_seconds{composer, endpoint} — histogram, end-to-end latency композитного endpoint'а. composition_downstream_duration_seconds{composer, downstream} — histogram, latency per downstream. composition_downstream_errors_total{composer, downstream, reason} — counter, reason ∈ {timeout, connection, 5xx, breaker_open}. composition_partial_failures_total{composer, downstream} — counter, сколько раз optional-downstream упал. composition_cache_hits_total{composer}, composition_cache_misses_total{composer} — counter, попадания/промахи в Redis-кэш.

Alert-шаблоны:

  • Растёт доля partial failures. rate(composition_partial_failures_total[5m]) / rate(composition_duration_seconds_count[5m]) > 0.05 10 мин → ticket. 5%+ optional-downstream деградируют.
  • Critical downstream timeout’ит часто. rate(composition_downstream_errors_total{reason="timeout"}[5m]) > 0.01 * rate(composition_duration_seconds_count[5m]) → страница.
  • P99 endpoint’а вышла за SLO. Типовое: p99 /v1/places/{id}/details

    500ms → ticket. SLO-дефиниции per-endpoint живут в infra-репо.

Anti-patterns

Shared database вместо composition

«Все сервисы пишут в одну общую Postgres-БД (shared database), composer делает JOIN» — нарушает database-per-service, ломает независимый деплой, создаёт скрытые FK между сервисами. У нас такой топологии нет: каждый сервис владеет отдельной физической БД, cross-database JOIN невозможен. Запрещено. См. ../conventions/db-pgx.md §Внешние ID — BIGINT без FK.

Синхронные композиции > 5 downstream

Composer зовёт 7 сервисов, считает, умножает — с каждым downstream growing fragility. Если каждый доступен 99.9%, combined uptime (1 - 0.001)^7 = 99.3% → 5 минут простоя в неделю. Решение: свернуть часть downstream в projection (см. §Когда НЕ использовать composition).

Нет timeout per downstream

Один медленный downstream → composer зависает → все handler’ы pod’а забиты. См. §Timeouts — always-timeouted.

Loop вместо batch (N+1)

См. §N+1 problem. Для list-endpoint’ов всегда batch, даже если приходится добавить endpoint в downstream.

Нет fallback при open circuit

Breaker открылся → composer просто упал с 500 → UI показал «ошибка сервера». Правильный fallback: для optional возвращаем empty, для critical — 503 с Retry-After. См. §Partial failure.

Composition из composer’а в composer

Service A zovет service B, который в свою очередь зовёт C, D, E (B — тоже композитор). Это цепочка, в которой timeout B складывается из timeouts C+D+E. Длинная цепочка быстро выпадает за SLO. Композиция должна быть плоской: composer зовёт владельцев данных напрямую, не других composer’ов. Если владельца не видно — это повод пересмотреть границы сервисов.

Проглотить ошибку молча

r, _ := s.reviewClient.GetRating(ctx, id) // ИГНОР // дальше используем r как будто всё ок

Скрытая потеря данных. На ревью такое всегда блокируется — любой error должен быть либо logged+metric (для optional), либо пробросан (для critical).

Когда НЕ использовать composition — использовать projection

Сигналы, что composition перестаёт справляться:

  • Read нагрузка >> write нагрузки (×10+). Зачем при каждом read ходить в 4 downstream, если write редки? Projection: review-service публикует review.updated, composer-side сервис подписан и обновляет read-model-таблицу. Read делает один SQL-запрос вместо composition’а.
  • Cross-service search/filter. «Найти все places с рейтингом > 4 в радиусе 1km» — невозможно выполнить composition’ом, нужна единая таблица/индекс, куда свели данные из catalog и review. Это projection с поисковым движком (search engine) под капотом.
  • Latency-sensitive (< 50ms p95) с 3+ downstream. Каждый HTTP-хоп в кластере — ~1–10ms. 3+ downstream в composition → latency fund’ament’ally > 30ms. Projection даёт один SQL → 5ms.
  • Availability-sensitive. Composer падает, если любой critical downstream упал. Projection — read-model локально, composer не зависит от доступности source-сервисов в момент запроса.

Выбор projection — серьёзный шаг: нужны событийная связь, projection- handler, cleanup/rebuild механизм, TTL/staleness мониторинг. См. cqrs про projection-подход в рамках lite-CQRS и ../architecture-overview про «планируется к подключению» в разделе Data management.

Пока у нас проекций нет. Composition закрывает текущие кейсы.

FAQ

«Gateway может делать composition?» Технически edge-слой умеет, но у нас policy — Gateway только routing (Cilium Gateway API) + TLS + JWT (api-gateway). Composition в Go-коде сервиса-владельца, не на edge.

«А если composer сам нужен в другом composition’е?» Это двойная композиция, см. §Anti-patterns. Обычно сигнал, что границы сервисов поставлены неудачно. Обсудить на архитектурном ревью, не городить цепочку.

«Можно ли кэшировать per-downstream, а не весь ответ целиком?» Можно, но сложнее invalidation (нужен invalidation per-downstream- событие). Per-response caching с коротким TTL — проще и обычно достаточно.

«Что делать, если downstream отвечает медленно, но правильно?» Это именно кейс для circuit breaker’а: p99 > threshold → breaker open → composer возвращает degraded ответ быстро, не ждёт. Health всего сервиса сохраняется.

«Почему не gRPC для internal-вызовов — быстрее?» gRPC явно не используем (см. ../architecture-overview.md §Что мы ЯВНО не делаем). HTTP REST + http/2 (через otelhttp.Transport) даёт сопоставимую latency для наших объёмов, проще в отладке (curl, dev-tools).

«Нужен ли hedging — дублировать запрос в downstream, ждать первый ответ?» Помогает при spike’ах latency. Реализуется поверх HTTP- клиента, но добавляет нагрузку на downstream в 2× — стоит включать только для p99-sensitive endpoint’ов, когда обычные методы (кэш, breaker, retry) уже применены.

Связанные разделы

Last updated on