API Composition¶
Deep-dive по паттерну composition'а: как собрать ответ клиенту из данных
нескольких сервисов без shared database и без cross-service JOIN.
Reference по HTTP — ../conventions/http-api.md.
Эта страница — про архитектурный выбор, обработку partial failure,
кэширование, N+1 и когда вместо composition надо использовать projection.
Содержание¶
- Проблема
- Варианты
- Наш выбор
- Архитектура
- Реализация
- Timeouts
- Partial failure
- Caching
- Circuit breaker
- N+1 problem
- Testing
- Monitoring
- Anti-patterns
- Когда НЕ использовать composition — использовать projection
- FAQ
- Связанные разделы
Проблема¶
UI запрашивает GET /v1/places/{id}/details и ждёт ответ со всеми
деталями места:
- Имя места, адрес, координаты — принадлежат catalog-сервису.
- Средний рейтинг, количество отзывов — review-сервис.
- Фотографии — media-сервис.
- Ближайшие события в этом месте — community-сервис.
Каждое из этих данных владеется своим сервисом. Shared database
запрещена (см. ../architecture-overview.md
§Data management). Cross-schema JOIN невозможен. Клиент ждёт один
ответ, а не четыре параллельных HTTP-запроса со сторедой координации.
API Composition — паттерн, когда один сервис выступает композитором: принимает запрос, параллельно опрашивает несколько downstream-сервисов, собирает ответ и отдаёт клиенту.
Варианты¶
API Composer в Gateway / BFF¶
Gateway (Traefik) или отдельный BFF-сервис делает composition'ы. Плюс — downstream-сервисы остаются простыми, каждый знает только свою область. Минус — Gateway растёт, BFF становится отдельным сервисом с своей логикой.
Не используем. Traefik у нас — роутинг + TLS + JWT, не бизнес-
логика. 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'ом.
sequenceDiagram
autonumber
participant C as Client
participant P as Place Service
participant R as Review Service
participant M as Media Service
participant Ca as Catalog Service
C->>P: GET /v1/places/42/details
par Параллельно (errgroup)
P->>R: GET /internal/ratings?place_id=42
P->>M: GET /internal/photos?place_id=42
P->>Ca: GET /internal/places/42
end
R-->>P: {rating: 4.5, count: 120}
M-->>P: [photos]
Ca-->>P: {name, address, hours}
P->>P: assemble PlaceDetails{}
P-->>C: 200 OK + PlaceDetails
Ключевое: всё в один 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'ом на входе:
- Никогда без timeout.
http.Client{}безTimeout— один «зависший» downstream повесит весь composition навсегда. Семафор handler'ов в сервере забьётся, упадёт latency всех endpoint'ов.
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
}
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.ReadyToTrip—consecutiveFailures > 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.md.
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.0510 мин → 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}/details500ms → ticket. SLO-дефиниции per-endpoint живут в infra-репо.
Anti-patterns¶
Shared database вместо composition¶
«Все сервисы пишут в одну Postgres-БД, composer делает JOIN» —
нарушает database-per-service, ломает независимый деплой, создаёт
скрытые FK между сервисами. Запрещено. См.
../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'ов. Если владельца не видно — это повод пересмотреть границы сервисов.
Проглотить ошибку молча¶
Скрытая потеря данных. На ревью такое всегда блокируется — любой 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.md про projection-подход в рамках lite-CQRS и
../architecture-overview.md про
«планируется к подключению» в разделе Data management.
Пока у нас проекций нет. Composition закрывает текущие кейсы.
FAQ¶
«Gateway может делать composition?» Технически Traefik умеет, но у нас policy — Gateway только routing + TLS + JWT. Composition в Go-коде сервиса, не в конфигурации Gateway.
«А если 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) уже применены.
Связанные разделы¶
../architecture-overview.md— API composition в общем каталоге паттернов, circuit breaker, timeouts.../conventions/http-api.md— HTTP- клиент, middleware,/v1/*vs/internal/*.../conventions/error-handling.md— error mapping,ErrUpstreamTimeout,ErrUpstreamUnavailable.../conventions/observability.md— tracing через composer+downstream, метрики.cqrs.md— альтернативный подход через projection.outbox.md— как source-сервисы публикуют события для будущих projection'ов.../glossary.md— API composition, API Gateway.