API Composition
Deep-dive по паттерну composition’а: как собрать ответ клиенту из данных
нескольких сервисов без shared database и без cross-service JOIN.
Reference по HTTP — ../conventions/http-api.
Эта страница — про архитектурный выбор, обработку partial failure,
кэширование, N+1 и когда вместо composition надо использовать projection.
Содержание
- Проблема
- Варианты
- Наш выбор
- Архитектура
- Реализация
- Timeouts
- Распределение timeout между downstream
- Ограничение concurrency
- Partial failure
- Контракт partial-failure в response
- 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-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 pool | 10–20 | Transport.MaxConnsPerHost HTTP-клиента composer’а. Лимит composition’а не должен превышать этот размер, иначе goroutines встанут на pool wait. |
| Per-downstream service throughput | зависит от SLO downstream’а | Не выжимай весь запас downstream на один batch — оставь запас на штатный трафик. |
| Composer’s own DB pool | POOL_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 status — 200 если все mandatory доставлены (даже если
optional отсутствуют).
206 Partial Contentне используем — этот статус исторически про byte-range-responses, его использование для partial-data сбивает с толку клиентов и middleware (кэши, proxy). Partial-сигнал — только в теле, черезpartialblock. - Клиент 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}
| Поле | Тип | Категория |
|---|---|---|
id | int64 | mandatory |
author_id | int64 | mandatory |
text | string | mandatory |
rating | int | mandatory |
author_profile | AuthorProfile | optional (из user-service) |
media | []Photo | optional (из media-service) |
reactions_summary | ReactionsSummary | optional (из 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.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.
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-БД (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) уже применены.
Связанные разделы
../architecture-overview— API composition в общем каталоге паттернов, circuit breaker, timeouts.../conventions/http-api— HTTP- клиент, middleware,/v1/*vs/internal/*.../conventions/error-handling— error mapping,ErrUpstreamTimeout,ErrUpstreamUnavailable.../conventions/observability— tracing через composer+downstream, метрики.cqrs— альтернативный подход через projection.outbox— как source-сервисы публикуют события для будущих projection’ов.../glossary— API composition, API Gateway.