Структурные паттерны микросервисов
API Gateway, BFF, Sidecar, Service Mesh, Strangler Fig, ACL, Service Discovery — как организовать взаимодействие сервисов. Примеры на Spring Cloud Gateway.
Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс. Эта статья описывает, как сервисы взаимодействуют друг с другом, как маршрутизируется трафик и как организуется доступ клиентов к backend-системе. Каждый паттерн сопровождается описанием проблемы, решением, схемой и примером кода на Spring Boot / Spring Cloud Gateway.
1. API Gateway
Проблема
В микросервисной архитектуре десятки сервисов, каждый со своим адресом, портом и протоколом аутентификации. Если клиент обращается к ним напрямую, он должен знать адреса всех сервисов, самостоятельно обрабатывать аутентификацию для каждого, а при добавлении нового сервиса — обновлять код клиента.
Дополнительные сквозные задачи — rate limiting, логирование, CORS, SSL termination — приходится реализовывать в каждом сервисе отдельно.
Решение
Единая точка входа (API Gateway) принимает все клиентские запросы и маршрутизирует их к нужным сервисам. Gateway берёт на себя сквозные задачи: аутентификацию, авторизацию, rate limiting, логирование, трансформацию запросов.
Пример
# application.yml — Spring Cloud Gateway
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
filters:
- StripPrefix=1
// GlobalAuthFilter.java — сквозная аутентификация на уровне Gateway
@Component
public class GlobalAuthFilter implements GlobalFilter, Ordered {
private final JwtDecoder jwtDecoder;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders()
.getFirst(HttpHeaders.AUTHORIZATION);
if (token == null || !token.startsWith("Bearer ")) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
try {
Jwt jwt = jwtDecoder.decode(token.substring(7));
// Передаём данные пользователя downstream-сервисам
ServerHttpRequest request = exchange.getRequest().mutate()
.header("X-User-Id", jwt.getSubject())
.header("X-User-Roles", String.join(",", jwt.getClaimAsStringList("roles")))
.build();
return chain.filter(exchange.mutate().request(request).build());
} catch (JwtException e) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
@Override
public int getOrder() { return -1; }
}
Когда использовать
- Много микросервисов и нужна единая точка входа
- Сквозные задачи (auth, logging, rate limiting) дублируются в сервисах
- Нужно скрыть внутреннюю топологию от клиентов
Когда не использовать
- Монолитное приложение — Gateway добавляет ненужный hop
- Один-два сервиса — проще настроить всё напрямую
2. Gateway Routing
Проблема
API Gateway должен направлять запрос к конкретному сервису на основании URL, HTTP-метода, заголовков или других признаков. Без явной маршрутизации Gateway становится «чёрным ящиком», в котором невозможно понять, куда уходит каждый запрос.
Решение
Gateway Routing — паттерн, при котором Gateway маршрутизирует каждый запрос к одному конкретному backend-сервису на основании набора предикатов (predicates). В отличие от Gateway Aggregation, здесь нет объединения ответов — один запрос всегда идёт к одному сервису.
Пример
Маршрутизация может быть основана на разных критериях:
spring:
cloud:
gateway:
routes:
# По пути
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
# По пути + HTTP-методу
- id: payment-service-write
uri: lb://payment-service
predicates:
- Path=/api/payments/**
- Method=POST,PUT,DELETE
# По заголовку (версионирование API)
- id: user-service-v2
uri: lb://user-service-v2
predicates:
- Path=/api/users/**
- Header=X-API-Version, v2
- id: user-service-v1
uri: lb://user-service-v1
predicates:
- Path=/api/users/**
# По query-параметру
- id: search-service
uri: lb://search-service
predicates:
- Path=/api/products/**
- Query=search
# По времени (canary deployment)
- id: new-catalog-service
uri: lb://catalog-service-v2
predicates:
- Path=/api/catalog/**
- Weight=catalog-group, 20 # 20% трафика
- id: old-catalog-service
uri: lb://catalog-service-v1
predicates:
- Path=/api/catalog/**
- Weight=catalog-group, 80 # 80% трафика
Помимо маршрутизации, Gateway может модифицировать запрос на лету:
- id: legacy-adapter
uri: lb://new-service
predicates:
- Path=/old-api/v1/**
filters:
- StripPrefix=2 # /old-api/v1/orders → /orders
- AddRequestHeader=X-Source, legacy # пометка источника
- RewritePath=/old-api/v1/(?<path>.*), /api/${path}
Когда использовать
- Нужно скрыть адреса внутренних сервисов за единым URL
- Разные версии API обслуживаются разными сервисами
- Canary-деплой: часть трафика направляется на новую версию
3. Gateway Aggregation
Проблема
Клиенту нужны данные из нескольких сервисов одновременно. Например, страница заказа требует данные из Order Service, User Service и Delivery Service. Если клиент делает три отдельных запроса — это три round-trip по сети, что медленно на мобильных устройствах с высоким latency.
Решение
Gateway принимает один запрос от клиента, параллельно вызывает несколько backend-сервисов и собирает ответы в единый агрегированный ответ.
Пример
@RestController
@RequiredArgsConstructor
public class OrderDetailsAggregationController {
private final WebClient.Builder webClientBuilder;
@GetMapping("/api/order-details/{orderId}")
public Mono<OrderDetailsResponse> getOrderDetails(@PathVariable Long orderId) {
Mono<OrderDto> orderMono = webClientBuilder.build()
.get()
.uri("http://order-service/orders/{id}", orderId)
.retrieve()
.bodyToMono(OrderDto.class)
.onErrorResume(e -> Mono.empty()); // partial failure
Mono<UserDto> userMono = webClientBuilder.build()
.get()
.uri("http://user-service/users/{id}", getUserIdFromOrder(orderId))
.retrieve()
.bodyToMono(UserDto.class)
.onErrorResume(e -> Mono.empty());
Mono<DeliveryDto> deliveryMono = webClientBuilder.build()
.get()
.uri("http://delivery-service/deliveries?orderId={id}", orderId)
.retrieve()
.bodyToMono(DeliveryDto.class)
.onErrorResume(e -> Mono.empty());
// Собираем параллельно
return Mono.zip(orderMono, userMono, deliveryMono)
.map(tuple -> new OrderDetailsResponse(tuple.getT1(), tuple.getT2(), tuple.getT3()));
}
}
Отличие от BFF
Gateway Aggregation — это агрегация на уровне инфраструктуры (API Gateway). Она не адаптирует данные под конкретного клиента, а просто собирает ответы в один.
BFF — это отдельный сервис, который дополнительно трансформирует и фильтрует данные под нужды конкретного типа клиента.
Когда использовать
- Клиенту нужны данные из 2-3 сервисов, и агрегация тривиальна
- Снизить количество round-trip для клиентов с высоким latency (мобильные)
Когда не использовать
- Нужна сложная трансформация данных — это задача BFF
- Агрегация требует бизнес-логики (вычисления, фильтрация по правилам)
- Больше 3-4 сервисов — Gateway становится «толстым»
4. Backend for Frontend (BFF)
Проблема
У приложения несколько типов клиентов: мобильное приложение, SPA-админка, публичный сайт. Каждому нужны разные данные, разные форматы ответов и разные модели безопасности.
Мобильному приложению нужен минимум данных (экономия трафика), веб-админке — полный набор с правами доступа, публичному сайту — только публичные данные. Один API не может эффективно обслуживать все эти нужды.
Решение
Для каждого типа клиента создаётся отдельный BFF-сервис. BFF не содержит бизнес-логики — он вызывает доменные сервисы и адаптирует ответы под своего клиента.
Пример
// Mobile BFF — минимум данных, stateless, Bearer Token
@RestController
@RequiredArgsConstructor
public class MobileOrderController {
private final OrderServiceClient orderClient;
private final UserServiceClient userClient;
@GetMapping("/api/orders/{id}")
public MobileOrderResponse getOrder(@PathVariable Long id) {
OrderDto order = orderClient.getOrder(id);
UserDto user = userClient.getUser(order.getUserId());
// Адаптация для мобильного: только нужные поля
return MobileOrderResponse.builder()
.orderId(order.getId())
.status(order.getStatus())
.totalAmount(order.getTotalAmount())
.userName(user.getFirstName())
.build();
}
}
// Web BFF — полный набор данных, stateful сессии, OAuth2
@RestController
@RequiredArgsConstructor
public class AdminOrderController {
private final OrderServiceClient orderClient;
private final UserServiceClient userClient;
private final PaymentServiceClient paymentClient;
private final AuditServiceClient auditClient;
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/admin/orders/{id}")
public AdminOrderResponse getOrder(@PathVariable Long id) {
OrderDto order = orderClient.getOrder(id);
UserDto user = userClient.getUser(order.getUserId());
PaymentDto payment = paymentClient.getPayment(order.getPaymentId());
List<AuditLogDto> auditLog = auditClient.getLogsForOrder(id);
return AdminOrderResponse.builder()
.orderId(order.getId())
.status(order.getStatus())
.totalAmount(order.getTotalAmount())
.user(AdminUserDto.from(user))
.payment(AdminPaymentDto.from(payment))
.auditLog(auditLog)
.internalNotes(order.getInternalNotes())
.build();
}
}
Отличие от API Gateway
- API Gateway — инфраструктурный компонент. Маршрутизирует запросы и решает сквозные задачи.
- BFF — прикладной сервис. Знает про бизнес-контекст своего клиента и адаптирует данные:
- выбирает, какие поля возвращать;
- агрегирует данные с учётом потребностей клиента;
- реализует модель безопасности (stateless для мобильных, сессии для веба).
Когда использовать
- Больше одного типа клиента с разными потребностями
- Клиентам нужны разные подмножества данных или разные форматы
- Разные модели безопасности (stateless vs stateful, JWT vs sessions)
Когда не использовать
- Один тип клиента — достаточно API Gateway
- Все клиенты потребляют одинаковые данные
5. Gateway Offloading
Проблема
Каждый микросервис самостоятельно реализует SSL termination, аутентификацию, сжатие ответов, CORS, IP whitelisting. Это дублирование кода, усложнение конфигурации и потенциальные расхождения в реализации.
Решение
Сквозные задачи, не связанные с бизнес-логикой, выносятся на уровень Gateway. Внутренние сервисы работают по простому HTTP без SSL и без аутентификации — это делает Gateway.
Пример
server:
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-type: PKCS12
compression:
enabled: true
mime-types: application/json,text/html
min-response-size: 1024
spring:
cloud:
gateway:
default-filters:
# Аутентификация — один раз для всех маршрутов
- name: JwtAuthFilter
# Сжатие ответов
- name: ModifyResponseBody
args:
rewriteFunction: GzipRewriteFunction
# Rate limiting по IP
- name: RequestRateLimiter
args:
key-resolver: "#{@ipKeyResolver}"
redis-rate-limiter.replenishRate: 100
redis-rate-limiter.burstCapacity: 200
# Стандартные security headers
- AddResponseHeader=X-Content-Type-Options, nosniff
- AddResponseHeader=X-Frame-Options, DENY
- AddResponseHeader=Strict-Transport-Security, max-age=31536000
После offloading внутренний сервис становится проще:
@RestController
public class OrderController {
@GetMapping("/orders/{id}")
public OrderDto getOrder(
@PathVariable Long id,
@RequestHeader("X-User-Id") String userId) {
// Gateway уже проверил JWT и передал userId в заголовке
return orderService.getOrder(id, userId);
}
}
Что выносить на Gateway, а что оставить в сервисе
Выносить на Gateway:
- SSL termination
- JWT-валидация (проверка подписи, срок действия)
- Rate limiting
- CORS
- Сжатие ответов
- Security headers
Оставить в сервисе:
- Авторизация (проверка прав на конкретный ресурс) — Gateway не знает бизнес-логику
- Валидация тела запроса — зависит от доменной модели
6. Sidecar
Проблема
Некоторые задачи одинаковы для всех сервисов: сбор метрик, логирование, шифрование трафика, service discovery. Реализовывать их в каждом сервисе — дублирование. А если сервисы написаны на разных языках (Java, Go, Python) — нужно писать одну и ту же логику на трёх языках.
Решение
Рядом с каждым сервисом деплоится вспомогательный процесс (sidecar), который берёт на себя инфраструктурные задачи. Sidecar и основной сервис живут в одном поде (Kubernetes) и общаются через localhost.
Пример
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
template:
spec:
containers:
# Основной сервис
- name: order-service
image: order-service:1.0
ports:
- containerPort: 8080
# Sidecar — Envoy Proxy
- name: envoy-sidecar
image: envoyproxy/envoy:v1.28
ports:
- containerPort: 9901 # admin
- containerPort: 15001 # inbound
- containerPort: 15006 # outbound
volumeMounts:
- name: envoy-config
mountPath: /etc/envoy
volumes:
- name: envoy-config
configMap:
name: order-service-envoy-config
Когда использовать
- Полиглотный стек (сервисы на разных языках)
- Нужна единообразная обработка сетевого трафика (mTLS, retry, circuit breaker)
- Инфраструктурные задачи не должны зависеть от языка/фреймворка
Когда не использовать
- Все сервисы на одном языке/фреймворке — проще библиотеки (Resilience4j)
- Sidecar добавляет latency и потребляет ресурсы
7. Service Mesh
Проблема
С ростом количества сервисов управление сетевым взаимодействием усложняется экспоненциально. Нужно контролировать: кто с кем может общаться (network policies), шифрование трафика (mTLS), retry и timeout для каждой пары сервисов, canary deployment, observability.
Паттерн Sidecar решает это для одного сервиса, но кто управляет всеми сайдкарами?
Решение
Service Mesh — инфраструктурный слой, который управляет всей межсервисной коммуникацией. Состоит из:
- Data Plane — сайдкар-прокси (Envoy) в каждом поде, через которые проходит весь трафик
- Control Plane — управляющий компонент (Istio, Linkerd), который конфигурирует все прокси
Пример
# Istio VirtualService — canary deployment (20% трафика на v2)
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
timeout: 10s
# Istio DestinationRule — circuit breaker
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: payment-service
spec:
host: payment-service
trafficPolicy:
outlierDetection:
consecutive5xxErrors: 3
interval: 30s
baseEjectionTime: 60s
maxEjectionPercent: 50
# Istio PeerAuthentication — mTLS для всего namespace
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: production
spec:
mtls:
mode: STRICT
Service Mesh vs API Gateway
- API Gateway обрабатывает north-south трафик (клиент → система).
- Service Mesh управляет east-west трафиком (сервис → сервис).
Когда использовать
- Десятки сервисов с активной межсервисной коммуникацией
- Строгие требования к безопасности (mTLS everywhere)
- Нужен canary deployment и fine-grained traffic management
- Полиглотный стек
Когда не использовать
- Меньше 10 сервисов — overhead перевесит выгоду
- Команда не готова к операционной сложности (Istio непрост в поддержке)
- Достаточно библиотечного подхода (Spring Cloud, Resilience4j)
8. Strangler Fig
Проблема
Есть работающий монолит, который нужно перевести на микросервисы. Переписать всё с нуля — рискованно и долго. Остановить разработку новых фич на время миграции — бизнес не позволит.
Решение
Новая функциональность реализуется в микросервисах. Старая — постепенно «задушивается»: трафик переключается с монолита на новые сервисы по мере их готовности. Монолит продолжает работать для функций, которые ещё не мигрированы.
Название — по аналогии с фикусом-душителем (Strangler Fig), который обвивает дерево и постепенно замещает его.
Пример
# application.yml — Gateway на этапе миграции
spring:
cloud:
gateway:
routes:
# Новый Order Service — мигрированная функциональность
- id: new-order-service
uri: http://order-service:8080
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
# Новый User Service — мигрированная функциональность
- id: new-user-service
uri: http://user-service:8080
predicates:
- Path=/api/users/**
filters:
- StripPrefix=1
# Монолит — всё остальное (ещё не мигрировано)
- id: legacy-monolith
uri: http://monolith:8080
predicates:
- Path=/api/**
order: 9999 # самый низкий приоритет
Постепенная миграция с feature-flag:
@Component
@RequiredArgsConstructor
public class OrderFacade {
private final LegacyOrderService legacyService;
private final NewOrderServiceClient newServiceClient;
@Value("${feature.use-new-order-service:false}")
private boolean useNewService;
public OrderDto getOrder(Long id) {
if (useNewService) {
return newServiceClient.getOrder(id);
}
return legacyService.getOrder(id);
}
}
Этапы миграции
- Идентификация — выбираем модуль монолита для миграции (начинаем с наименее связанного)
- Реализация — создаём микросервис с тем же API
- Перенаправление — Gateway отправляет трафик на новый сервис
- Верификация — сравниваем ответы старого и нового сервиса (shadow traffic)
- Удаление — вырезаем код из монолита
Когда использовать
- Миграция работающего монолита на микросервисы
- Нельзя остановить разработку на время миграции
- Нужна возможность откатиться (переключить трафик обратно на монолит)
Когда не использовать
- Greenfield-проект — сразу делайте микросервисы
- Монолит работает хорошо и не мешает — не мигрируйте ради моды
9. Anti-Corruption Layer (ACL)
Проблема
Микросервис интегрируется с внешней или legacy-системой, у которой своя доменная модель, свои форматы данных, свои соглашения об именах. Если напрямую использовать модели внешней системы в своём коде — доменная логика засоряется чужими абстракциями. Смена внешней системы потребует переписывания всего сервиса.
Решение
Между сервисом и внешней системой ставится слой-переводчик (Anti-Corruption Layer), который преобразует модели внешней системы в доменные модели сервиса и обратно.
Пример
// Модель внешней системы — «чужие» имена и структура
public class ExternalPaymentResponse {
private String txn_id;
private int amount_cents;
private String ccy;
private String sts; // "S" = success, "F" = failed, "P" = pending
private long created_ts;
}
// Наша доменная модель — чистые, понятные имена
public record Payment(
UUID transactionId,
Money amount,
PaymentStatus status,
OffsetDateTime createdAt
) {}
public record Money(BigDecimal value, Currency currency) {}
public enum PaymentStatus { PENDING, SUCCESS, FAILED }
// ACL — адаптер, изолирующий внешнюю модель
@Component
@RequiredArgsConstructor
public class PaymentGatewayAdapter {
private final ExternalPaymentClient externalClient;
private final PaymentMapper mapper;
public Payment getPayment(UUID transactionId) {
ExternalPaymentResponse external =
externalClient.getTransaction(transactionId.toString());
return mapper.toDomain(external);
}
public Payment createPayment(Money amount, String description) {
ExternalCreatePaymentRequest request = mapper.toExternal(amount, description);
ExternalPaymentResponse response = externalClient.create(request);
return mapper.toDomain(response);
}
}
@Component
public class PaymentMapper {
public Payment toDomain(ExternalPaymentResponse external) {
return new Payment(
UUID.fromString(external.getTxnId()),
new Money(
BigDecimal.valueOf(external.getAmountCents(), 2), // центы → рубли
Currency.fromCode(external.getCcy())
),
mapStatus(external.getSts()),
Instant.ofEpochSecond(external.getCreatedTs())
.atOffset(ZoneOffset.UTC)
);
}
private PaymentStatus mapStatus(String externalStatus) {
return switch (externalStatus) {
case "S" -> PaymentStatus.SUCCESS;
case "F" -> PaymentStatus.FAILED;
case "P" -> PaymentStatus.PENDING;
default -> throw new IllegalArgumentException(
"Unknown payment status: " + externalStatus);
};
}
}
Когда использовать
- Интеграция с legacy-системой или внешним API с «чужой» доменной моделью
- Планируется замена внешней системы в будущем
- Доменная логика сервиса не должна зависеть от формата внешнего API
Когда не использовать
- Внешняя система использует стандартный формат, совпадающий с вашей моделью
- Простая интеграция без бизнес-логики (прокси без трансформации)
10. Service Registry & Discovery
Проблема
В микросервисной архитектуре сервисы масштабируются динамически — сегодня 3 инстанса Order Service, завтра 10. Инстансы приходят и уходят: деплои, автоскейлинг, падения. Хардкодить адреса невозможно.
Решение
Service Registry — реестр, в котором каждый сервис регистрируется при старте и отменяет регистрацию при остановке. Другие сервисы узнают адреса через этот реестр.
Два подхода:
- Client-Side Discovery — клиент сам запрашивает реестр и выбирает инстанс (Eureka, Consul)
- Server-Side Discovery — запрос проходит через балансировщик, который знает про реестр (Kubernetes Services, AWS ALB)
Пример: Client-Side Discovery (Spring Cloud Eureka)
eureka:
client:
service-url:
defaultZone: http://eureka-server:8761/eureka
instance:
prefer-ip-address: true
lease-renewal-interval-in-seconds: 10
lease-expiration-duration-in-seconds: 30
@FeignClient(name = "payment-service") // Eureka resolves by name
public interface PaymentServiceClient {
@PostMapping("/payments")
PaymentDto createPayment(@RequestBody CreatePaymentRequest request);
}
Пример: Server-Side Discovery (Kubernetes)
В Kubernetes service discovery встроено — каждый Service получает DNS-имя:
apiVersion: v1
kind: Service
metadata:
name: payment-service
spec:
selector:
app: payment-service
ports:
- port: 8080
payment-service:
url: http://payment-service:8080
Kubernetes DNS автоматически резолвит payment-service в IP одного из Pod-ов. Не нужен отдельный реестр, Eureka или Consul.
Когда использовать
- Eureka / Consul — если вы не в Kubernetes или нужны дополнительные возможности (health checks, KV store)
- Kubernetes DNS — если вы в Kubernetes (встроенный, бесплатный, ничего настраивать не нужно)
Карта паттернов
Начните с простого
Если вы начинаете новый проект на микросервисах:
- API Gateway + Gateway Routing — единая точка входа, маршрутизация по путям
- Service Discovery — Kubernetes DNS, если вы в K8s
- Gateway Offloading — вынесите SSL и auth на Gateway
По мере роста:
- BFF — когда появится второй тип клиента
- Gateway Aggregation — когда клиенту нужны данные из нескольких сервисов
- Anti-Corruption Layer — при интеграции с legacy
Для больших систем (50+ сервисов):
- Service Mesh — когда управление east-west трафиком становится сложным
- Strangler Fig — когда нужно мигрировать монолит
Ссылки
- Кейс: маркетплейс — где применяются эти паттерны на практике.
- Распределённые паттерны — Saga, Outbox, Event Sourcing.
- Паттерны отказоустойчивости — Circuit Breaker, Retry, Bulkhead.
- Apache Kafka — асинхронная коммуникация между сервисами.
- C4 Model — как описывать архитектуру с этими паттернами.