Перейти к содержанию

API Composition

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

Содержание

Проблема

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'ом на входе:
    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'ов.

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.
  • 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.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.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-БД, 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'ов. Если владельца не видно — это повод пересмотреть границы сервисов.

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

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.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) уже применены.

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