← назад к разделу

Когда приложение разбито на несколько сервисов, сразу возникают вопросы: как клиент находит нужный сервис? Кто проверяет токен — каждый сервис отдельно или кто-то один? Как постепенно переехать со старого монолита? Структурные паттерны — это готовые ответы на эти вопросы.

Разберём десять самых распространённых: от простых шлюзов до Service Mesh.

API Gateway

Представьте, что у вас в городе десяток ресторанов, но каждый по своему адресу. Вместо того чтобы запоминать все адреса, люди идут в один торговый центр — а там уже понятно, куда идти. API Gateway — это такой торговый центр для ваших сервисов.

Проблема. Клиент (мобильное приложение, браузер) не должен знать адреса всех сервисов внутри системы. Если каждый сервис сам проверяет токен, ставит лимиты на запросы и логирует — это одинаковый код в десяти местах.

Решение. Gateway принимает все входящие запросы и направляет их в нужный сервис. Сквозные задачи — аутентификация, ограничение числа запросов, CORS, логирование — делаются здесь один раз.

diagram

Пример маршрутизации в шлюзе на FastAPI + httpx:

ROUTES = {
    "/api/orders": "http://order-service:8000",
    "/api/users": "http://user-service:8000",
}

app = FastAPI()
client = httpx.AsyncClient()


@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy(path: str, request: Request) -> Response:
    upstream = resolve_upstream(request.url.path)  # по префиксу пути
    response = await client.request(
        request.method,
        f"{upstream}/{path}",
        headers=request.headers.raw,
        content=await request.body(),
    )
    return Response(response.content, status_code=response.status_code)

Gateway проверяет JWT один раз и передаёт идентификатор пользователя в сервисы через заголовок — сервисам не нужно заниматься этим самостоятельно:

@app.middleware("http")
async def global_auth(request: Request, call_next: Callable) -> Response:
    token = request.headers.get("Authorization")
    if token is None or not token.startswith("Bearer "):
        return Response(status_code=401)

    try:
        claims = jwt.decode(token[7:], PUBLIC_KEY, algorithms=["RS256"])
    except jwt.InvalidTokenError:
        return Response(status_code=401)

    headers = MutableHeaders(scope=request.scope)
    headers["X-User-Id"] = claims["sub"]
    headers["X-User-Roles"] = ",".join(claims.get("roles", []))
    return await call_next(request)

Когда нужен: много сервисов, нужна единая точка входа, сквозные задачи дублируются в каждом сервисе.

Когда не нужен: монолит или 1–2 сервиса — Gateway добавляет лишний сетевой переход без выгоды.

Gateway Routing

Проблема. Запросы нужно направлять к конкретному сервису в зависимости от URL, HTTP-метода или заголовков. Без явных правил маршрутизации невозможно понять, куда уходит каждый запрос.

Решение. Gateway Routing — это набор правил (предикатов): если URL начинается с /api/orders/, идёт в Order Service; если пришёл заголовок X-API-Version: v2, идёт в новую версию сервиса.

@dataclass(frozen=True)
class Route:
    path_prefix: str
    upstream: str
    header: tuple[str, str] | None = None
    weight: int = 100


ROUTES = [
    # По пути
    Route("/api/orders/", "http://order-service:8000"),

    # По заголовку — версионирование API
    Route("/api/users/", "http://user-service-v2:8000", header=("X-API-Version", "v2")),
    Route("/api/users/", "http://user-service:8000"),

    # Распределение трафика — 20% на новую версию
    Route("/api/catalog/", "http://catalog-service-v2:8000", weight=20),
    Route("/api/catalog/", "http://catalog-service-v1:8000", weight=80),
]


def resolve_upstream(request: Request) -> str:
    matching = [
        r for r in ROUTES
        if request.url.path.startswith(r.path_prefix)
        and (r.header is None or request.headers.get(r.header[0]) == r.header[1])
    ]
    chosen = random.choices(matching, weights=[r.weight for r in matching])[0]
    return chosen.upstream

Когда нужен: разные версии API в разных сервисах, постепенное переключение трафика на новую версию (canary).

Gateway Aggregation

Проблема. Страница заказа показывает данные из трёх сервисов: Order Service, User Service, Delivery Service. Если браузер делает три отдельных запроса — это три обращения по сети. На мобильных устройствах с медленным соединением это заметно.

Решение. Gateway принимает один запрос, параллельно опрашивает все нужные сервисы и собирает ответ в единый объект. Клиент получает данные за один круговой обход.

diagram
@app.get("/api/order-details/{order_id}")
async def get_order_details(order_id: int) -> OrderDetailsResponse:
    user_id = get_user_id_from_order(order_id)

    async def fetch[T: BaseModel](url: str, model: type[T]) -> T | None:
        try:
            response = await client.get(url)
            response.raise_for_status()
            return model.model_validate(response.json())
        except httpx.HTTPError:
            return None

    order, user, delivery = await asyncio.gather(
        fetch(f"http://order-service/orders/{order_id}", OrderDto),
        fetch(f"http://user-service/users/{user_id}", UserDto),
        fetch(f"http://delivery-service/deliveries?orderId={order_id}", DeliveryDto),
    )
    return OrderDetailsResponse(order=order, user=user, delivery=delivery)

Когда нужен: клиенту нужны данные из 2–3 сервисов, агрегация простая, без бизнес-логики.

Когда не нужен: нужна сложная трансформация или фильтрация данных — это задача для BFF.

Backend for Frontend (BFF)

Проблема. У вас мобильное приложение, веб-админка и публичный сайт. Каждому нужен свой набор данных: мобильному — минимум (экономия трафика), админке — полный набор с журналом действий, публичному сайту — только открытые данные. Один общий API не может хорошо обслуживать всех.

Решение. Для каждого типа клиента создаётся отдельный BFF-сервис. Он сам обращается к доменным сервисам и возвращает только то, что нужно конкретному клиенту. Никакой бизнес-логики в BFF нет — только выбор и форматирование данных.

diagram

Mobile BFF возвращает только нужные поля:

# Mobile BFF — минимум данных
@app.get("/api/orders/{order_id}")
async def get_order(order_id: int) -> MobileOrderResponse:
    order = await order_client.get_order(order_id)
    user = await user_client.get_user(order.user_id)

    return MobileOrderResponse(
        id=order.id,
        status=order.status,
        total_amount=order.total_amount,
        first_name=user.first_name,
    )

Web BFF для администратора вытягивает данные из большего числа источников и добавляет поля, которых нет в мобильной версии.

Чем BFF отличается от API Gateway: Gateway — инфраструктурный компонент, маршрутизирует запросы. BFF — прикладной сервис, знает про потребности своего клиента и адаптирует данные. Они не конкурируют: Gateway стоит перед BFF.

Когда нужен: больше одного типа клиентов с разными потребностями в данных.

Когда не нужен: один тип клиента — достаточно API Gateway.

Gateway Offloading

Проблема. Каждый сервис самостоятельно настраивает SSL, проверяет токен, отдаёт заголовки безопасности, сжимает ответы. Это одинаковая работа в каждом сервисе.

Решение. Всё это выносится на Gateway. Внутренние сервисы работают по простому HTTP без SSL и без проверки токена — идентификатор пользователя они получают уже готовым из заголовка X-User-Id.

app.add_middleware(JwtAuthMiddleware)
app.add_middleware(RateLimitMiddleware, replenish_rate=100, burst_capacity=200)


@app.middleware("http")
async def security_headers(request: Request, call_next: Callable) -> Response:
    response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Strict-Transport-Security"] = "max-age=31536000"
    return response

Внутренний сервис просто читает заголовок — он не занимается JWT:

@app.get("/orders/{order_id}")
async def get_order(
    order_id: int,
    user_id: Annotated[str, Header(alias="X-User-Id")],
) -> OrderDto:
    return await order_service.get_order(order_id, user_id)

Что выносить на Gateway: SSL termination, проверка JWT-подписи, ограничение числа запросов, CORS, заголовки безопасности.

Что оставить в сервисе: авторизация (проверка прав на конкретный ресурс) — Gateway не знает бизнес-логику; валидация тела запроса — зависит от доменной модели.

Sidecar

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

Решение. Рядом с каждым сервисом запускается вспомогательный процесс (sidecar). Он берёт на себя инфраструктурные задачи, а основной сервис занимается только бизнес-логикой. В Kubernetes sidecar и основной контейнер живут в одном Pod-е и общаются через localhost.

diagram
spec:
  containers:
    - name: order-service
      image: order-service:1.0
      ports:
        - containerPort: 8080

    - name: envoy-sidecar
      image: envoyproxy/envoy:v1.28
      ports:
        - containerPort: 9901
        - containerPort: 15001
        - containerPort: 15006

Когда нужен: сервисы на разных языках, нужно единообразное шифрование трафика или повторные попытки на уровне сети.

Когда не нужен: все сервисы на одном языке — проще библиотека (tenacity для Python, Resilience4j для Java).

Service Mesh

Проблема. Sidecar решает задачу для одного сервиса. Но когда сервисов десятки — кто настраивает все эти прокси? Как управлять тем, кому с кем можно общаться? Как включить шифрование сразу для всей системы?

Решение. Service Mesh — инфраструктурный слой, который управляет всей сетью между сервисами. Он состоит из двух частей:

  • Data Plane — прокси (Envoy) в каждом Pod-е, через которые проходит весь трафик.
  • Control Plane — управляющий компонент (Istio, Linkerd), который раздаёт конфигурацию всем прокси.
diagram

Пример: Istio переключает 20% трафика на новую версию сервиса и настраивает повторные попытки:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: order-service
spec:
  hosts:
    - order-service
  http:
    - route:
        - destination:
            host: order-service
            subset: v1
          weight: 80
        - destination:
            host: order-service
            subset: v2
          weight: 20
      retries:
        attempts: 3
        perTryTimeout: 2s
        retryOn: 5xx,reset,connect-failure

API Gateway vs Service Mesh: Gateway обрабатывает трафик «снаружи внутрь» (клиент → система). Service Mesh управляет трафиком внутри системы (сервис → сервис).

Когда нужен: десятки сервисов, строгие требования к безопасности (шифрование между всеми сервисами), тонкое управление трафиком.

Когда не нужен: меньше 10 сервисов — сложность управления перевесит выгоду; достаточно библиотек вроде tenacity.

Strangler Fig

Проблема. Есть работающий монолит, который нужно перевести на микросервисы. Переписать всё сразу — рискованно и занимает год. Остановить новые функции на время переезда — невозможно.

Решение. Новая функциональность реализуется в микросервисах. Старая — постепенно переключается: трафик с монолита уходит в новые сервисы по мере их готовности. Монолит продолжает работать для всего, что ещё не переехало.

Название — по аналогии с растением фикус-душитель (Strangler Fig): оно обвивает дерево и со временем замещает его.

# Gateway на этапе переезда — первый совпавший маршрут побеждает
ROUTES = [
    Route("/api/orders/", "http://order-service:8000"),
    Route("/api/users/", "http://user-service:8000"),

    # Старый монолит — всё остальное
    Route("/api/", "http://monolith:8080"),
]

Переключение через флаг позволяет вернуться назад, если что-то пошло не так:

class OrderFacade:

    def __init__(
        self,
        legacy_service: LegacyOrderService,
        new_service_client: NewOrderServiceClient,
        settings: Settings,
    ) -> None:
        self.legacy_service = legacy_service
        self.new_service_client = new_service_client
        self.use_new_service = settings.use_new_order_service  # флаг из конфигурации

    async def get_order(self, order_id: int) -> OrderDto:
        if self.use_new_service:
            return await self.new_service_client.get_order(order_id)
        return self.legacy_service.get_order(order_id)

Типичный порядок переезда: выбрать наименее связанный модуль → создать микросервис с тем же API → переключить трафик через Gateway → сравнить ответы → убрать код из монолита.

Когда нужен: переезд с монолита без остановки разработки.

Когда не нужен: новый проект — начинайте с нужной архитектуры сразу; монолит, который работает хорошо и не мешает — не трогайте.

Anti-Corruption Layer (ACL)

Проблема. Ваш сервис интегрируется с внешней системой, у которой своя модель данных: поля называются txn_id, amount_cents, sts, статусы передаются кодами "S"/"F"/"P". Если напрямую использовать эти модели в бизнес-логике — весь код начнёт зависеть от чужих условностей. При замене внешней системы придётся переписывать половину сервиса.

Решение. Между сервисом и внешней системой ставится слой-переводчик. Он принимает «чужие» модели и преобразует их в понятные доменные объекты — и обратно при необходимости.

diagram
# Модель внешней системы
class ExternalPaymentResponse(BaseModel):
    txn_id: str
    amount_cents: int
    ccy: str
    sts: str          # "S" = success, "F" = failed, "P" = pending
    created_ts: int


# Наша доменная модель — понятные имена
@dataclass(frozen=True)
class Payment:
    transaction_id: UUID
    amount: Money
    status: PaymentStatus
    created_at: datetime


# Переводчик — изолирует внешнюю модель от домена
STATUS_MAP = {
    "S": PaymentStatus.SUCCESS,
    "F": PaymentStatus.FAILED,
    "P": PaymentStatus.PENDING,
}


def to_domain(external: ExternalPaymentResponse) -> Payment:
    if external.sts not in STATUS_MAP:
        raise ValueError(f"Unknown payment status: {external.sts}")
    return Payment(
        transaction_id=UUID(external.txn_id),
        amount=Money(
            Decimal(external.amount_cents) / 100,
            Currency.from_code(external.ccy),
        ),
        status=STATUS_MAP[external.sts],
        created_at=datetime.fromtimestamp(external.created_ts, tz=UTC),
    )

Когда нужен: интеграция с внешним API или старой системой с чужой моделью данных; когда планируется замена внешней системы.

Когда не нужен: внешний API полностью совпадает с вашей моделью данных и замена не планируется.

Service Registry и Service Discovery

Проблема. Сервисы масштабируются динамически: сегодня 3 экземпляра Order Service, завтра 10. Экземпляры появляются и исчезают при деплоях, автомасштабировании, перезапусках. Прописать адреса вручную невозможно.

Решение. Service Registry — реестр, в котором каждый сервис регистрируется при запуске. Другие сервисы ищут адреса через этот реестр. Есть два подхода:

  • Client-Side Discovery — клиент сам спрашивает реестр (Eureka, Consul) и выбирает экземпляр.
  • Server-Side Discovery — запрос проходит через балансировщик, который знает про реестр (Kubernetes Services).
diagram

С Consul клиент обращается к сервису по имени — реестр сам находит нужный экземпляр:

async def resolve_service(name: str) -> str:
    _, instances = await consul.health.service(name, passing=True)
    instance = random.choice(instances)["Service"]
    return f"http://{instance['Address']}:{instance['Port']}"


async def create_payment(request: CreatePaymentRequest) -> PaymentDto:
    base_url = await resolve_service("payment-service")
    response = await client.post(f"{base_url}/payments", json=request.model_dump())
    return PaymentDto.model_validate(response.json())

В Kubernetes service discovery встроен: каждый Service получает DNS-имя, и обращение http://payment-service:8080 автоматически попадает к одному из Pod-ов.

apiVersion: v1
kind: Service
metadata:
  name: payment-service
spec:
  selector:
    app: payment-service
  ports:
    - port: 8080

Когда что выбирать: Eureka/Consul — если не используете Kubernetes или нужны дополнительные возможности реестра. Kubernetes DNS — если вы в Kubernetes, он встроен и ничего настраивать не нужно.

С чего начать

Если вы строите систему из нескольких сервисов:

  1. API Gateway + Gateway Routing — единая точка входа, маршрутизация по путям.
  2. Service Discovery — Kubernetes DNS, если вы в K8s; иначе Consul или Eureka.
  3. Gateway Offloading — вынесите SSL и проверку токена на Gateway.

По мере роста:

  • BFF — когда появится второй тип клиентов.
  • Gateway Aggregation — когда клиентам нужны данные из нескольких сервисов за один запрос.
  • Anti-Corruption Layer — при интеграции со старой системой с чужой моделью данных.

Для больших систем:

  • Service Mesh — когда управление трафиком между десятками сервисов становится сложным.
  • Strangler Fig — когда нужно постепенно переехать с монолита.

Коротко

  • API Gateway — единая точка входа, которая берёт на себя auth, rate limiting, логирование.
  • Gateway Routing — правила маршрутизации: какой запрос идёт в какой сервис.
  • Gateway Aggregation — один запрос клиента → несколько параллельных вызовов → один ответ.
  • BFF — отдельный сервис-адаптер для каждого типа клиента (мобильный, веб, публичный).
  • Gateway Offloading — сквозные задачи (SSL, токены, заголовки) один раз на Gateway, а не в каждом сервисе.
  • Sidecar — вспомогательный процесс рядом с сервисом, берёт инфраструктурные задачи независимо от языка.
  • Service Mesh — управление всей сетью между сервисами через Control Plane и Envoy-прокси.
  • Strangler Fig — постепенный переезд с монолита: трафик переключается по частям, откат всегда возможен.
  • Anti-Corruption Layer — слой-переводчик между своей доменной моделью и чужим API.
  • Service Registry & Discovery — реестр живых экземпляров сервисов; в Kubernetes встроен.

Что почитать дальше