Когда приложение разбито на несколько сервисов, сразу возникают вопросы: как клиент находит нужный сервис? Кто проверяет токен — каждый сервис отдельно или кто-то один? Как постепенно переехать со старого монолита? Структурные паттерны — это готовые ответы на эти вопросы.
Разберём десять самых распространённых: от простых шлюзов до Service Mesh.
API Gateway
Представьте, что у вас в городе десяток ресторанов, но каждый по своему адресу. Вместо того чтобы запоминать все адреса, люди идут в один торговый центр — а там уже понятно, куда идти. API Gateway — это такой торговый центр для ваших сервисов.
Проблема. Клиент (мобильное приложение, браузер) не должен знать адреса всех сервисов внутри системы. Если каждый сервис сам проверяет токен, ставит лимиты на запросы и логирует — это одинаковый код в десяти местах.
Решение. Gateway принимает все входящие запросы и направляет их в нужный сервис. Сквозные задачи — аутентификация, ограничение числа запросов, CORS, логирование — делаются здесь один раз.
Пример конфигурации маршрутов в 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
Gateway проверяет JWT один раз и передаёт идентификатор пользователя в сервисы через заголовок — сервисам не нужно заниматься этим самостоятельно:
@Component
public class GlobalAuthFilter implements GlobalFilter, Ordered {
private final JwtDecoder jwtDecoder;
public GlobalAuthFilter(JwtDecoder jwtDecoder) {
this.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));
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; }
}
Когда нужен: много сервисов, нужна единая точка входа, сквозные задачи дублируются в каждом сервисе.
Когда не нужен: монолит или 1–2 сервиса — Gateway добавляет лишний сетевой переход без выгоды.
Gateway Routing
Проблема. Запросы нужно направлять к конкретному сервису в зависимости от URL, HTTP-метода или заголовков. Без явных правил маршрутизации невозможно понять, куда уходит каждый запрос.
Решение. Gateway Routing — это набор правил (предикатов): если URL начинается с /api/orders/, идёт в Order Service; если пришёл заголовок X-API-Version: v2, идёт в новую версию сервиса.
spring:
cloud:
gateway:
routes:
# По пути
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
# По заголовку — версионирование API
- id: user-service-v2
uri: lb://user-service-v2
predicates:
- Path=/api/users/**
- Header=X-API-Version, v2
# Распределение трафика — 20% на новую версию
- id: new-catalog-service
uri: lb://catalog-service-v2
predicates:
- Path=/api/catalog/**
- Weight=catalog-group, 20
- id: old-catalog-service
uri: lb://catalog-service-v1
predicates:
- Path=/api/catalog/**
- Weight=catalog-group, 80
Когда нужен: разные версии API в разных сервисах, постепенное переключение трафика на новую версию (canary).
Gateway Aggregation
Проблема. Страница заказа показывает данные из трёх сервисов: Order Service, User Service, Delivery Service. Если браузер делает три отдельных запроса — это три обращения по сети. На мобильных устройствах с медленным соединением это заметно.
Решение. Gateway принимает один запрос, параллельно опрашивает все нужные сервисы и собирает ответ в единый объект. Клиент получает данные за один круговой обход.
@RestController
public class OrderDetailsAggregationController {
private final WebClient.Builder webClientBuilder;
public OrderDetailsAggregationController(WebClient.Builder webClientBuilder) {
this.webClientBuilder = 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());
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()));
}
}
Когда нужен: клиенту нужны данные из 2–3 сервисов, агрегация простая, без бизнес-логики.
Когда не нужен: нужна сложная трансформация или фильтрация данных — это задача для BFF.
Backend for Frontend (BFF)
Проблема. У вас мобильное приложение, веб-админка и публичный сайт. Каждому нужен свой набор данных: мобильному — минимум (экономия трафика), админке — полный набор с журналом действий, публичному сайту — только открытые данные. Один общий API не может хорошо обслуживать всех.
Решение. Для каждого типа клиента создаётся отдельный BFF-сервис. Он сам обращается к доменным сервисам и возвращает только то, что нужно конкретному клиенту. Никакой бизнес-логики в BFF нет — только выбор и форматирование данных.
Mobile BFF возвращает только нужные поля:
// Mobile BFF — минимум данных
@RestController
public class MobileOrderController {
private final OrderServiceClient orderClient;
private final UserServiceClient userClient;
public MobileOrderController(OrderServiceClient orderClient,
UserServiceClient userClient) {
this.orderClient = orderClient;
this.userClient = userClient;
}
@GetMapping("/api/orders/{id}")
public MobileOrderResponse getOrder(@PathVariable Long id) {
OrderDto order = orderClient.getOrder(id);
UserDto user = userClient.getUser(order.getUserId());
return new MobileOrderResponse(
order.getId(),
order.getStatus(),
order.getTotalAmount(),
user.getFirstName()
);
}
}
Web BFF для администратора вытягивает данные из большего числа источников и добавляет поля, которых нет в мобильной версии.
Чем BFF отличается от API Gateway: Gateway — инфраструктурный компонент, маршрутизирует запросы. BFF — прикладной сервис, знает про потребности своего клиента и адаптирует данные. Они не конкурируют: Gateway стоит перед BFF.
Когда нужен: больше одного типа клиентов с разными потребностями в данных.
Когда не нужен: один тип клиента — достаточно API Gateway.
Gateway Offloading
Проблема. Каждый сервис самостоятельно настраивает SSL, проверяет токен, отдаёт заголовки безопасности, сжимает ответы. Это одинаковая работа в каждом сервисе.
Решение. Всё это выносится на Gateway. Внутренние сервисы работают по простому HTTP без SSL и без проверки токена — идентификатор пользователя они получают уже готовым из заголовка X-User-Id.
spring:
cloud:
gateway:
default-filters:
- name: JwtAuthFilter
- name: RequestRateLimiter
args:
key-resolver: "#{@ipKeyResolver}"
redis-rate-limiter.replenishRate: 100
redis-rate-limiter.burstCapacity: 200
- AddResponseHeader=X-Content-Type-Options, nosniff
- AddResponseHeader=X-Frame-Options, DENY
- AddResponseHeader=Strict-Transport-Security, max-age=31536000
Внутренний сервис просто читает заголовок — он не занимается JWT:
@RestController
public class OrderController {
@GetMapping("/orders/{id}")
public OrderDto getOrder(
@PathVariable Long id,
@RequestHeader("X-User-Id") String userId) {
return orderService.getOrder(id, userId);
}
}
Что выносить на Gateway: SSL termination, проверка JWT-подписи, ограничение числа запросов, CORS, заголовки безопасности.
Что оставить в сервисе: авторизация (проверка прав на конкретный ресурс) — Gateway не знает бизнес-логику; валидация тела запроса — зависит от доменной модели.
Sidecar
Проблема. Сбор метрик, шифрование трафика, повторные попытки при ошибках — это нужно каждому сервису. Если сервисы написаны на разных языках, одну и ту же логику нужно писать на каждом из них.
Решение. Рядом с каждым сервисом запускается вспомогательный процесс (sidecar). Он берёт на себя инфраструктурные задачи, а основной сервис занимается только бизнес-логикой. В Kubernetes sidecar и основной контейнер живут в одном Pod-е и общаются через localhost.
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
Когда нужен: сервисы на разных языках, нужно единообразное шифрование трафика или повторные попытки на уровне сети.
Когда не нужен: все сервисы на одном языке — проще библиотека (Resilience4j для Java, tenacity для Python).
Service Mesh
Проблема. Sidecar решает задачу для одного сервиса. Но когда сервисов десятки — кто настраивает все эти прокси? Как управлять тем, кому с кем можно общаться? Как включить шифрование сразу для всей системы?
Решение. Service Mesh — инфраструктурный слой, который управляет всей сетью между сервисами. Он состоит из двух частей:
- Data Plane — прокси (Envoy) в каждом Pod-е, через которые проходит весь трафик.
- Control Plane — управляющий компонент (Istio, Linkerd), который раздаёт конфигурацию всем прокси.
Пример: 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 сервисов — сложность управления перевесит выгоду; достаточно библиотек вроде Resilience4j.
Strangler Fig
Проблема. Есть работающий монолит, который нужно перевести на микросервисы. Переписать всё сразу — рискованно и занимает год. Остановить новые функции на время переезда — невозможно.
Решение. Новая функциональность реализуется в микросервисах. Старая — постепенно переключается: трафик с монолита уходит в новые сервисы по мере их готовности. Монолит продолжает работать для всего, что ещё не переехало.
Название — по аналогии с растением фикус-душитель (Strangler Fig): оно обвивает дерево и со временем замещает его.
# Gateway на этапе переезда
spring:
cloud:
gateway:
routes:
- id: new-order-service
uri: http://order-service:8080
predicates:
- Path=/api/orders/**
- id: new-user-service
uri: http://user-service:8080
predicates:
- Path=/api/users/**
# Старый монолит — всё остальное
- id: legacy-monolith
uri: http://monolith:8080
predicates:
- Path=/api/**
order: 9999
Переключение через флаг позволяет вернуться назад, если что-то пошло не так:
@Component
public class OrderFacade {
private final LegacyOrderService legacyService;
private final NewOrderServiceClient newServiceClient;
private final boolean useNewService;
public OrderFacade(LegacyOrderService legacyService,
NewOrderServiceClient newServiceClient,
@Value("${feature.use-new-order-service:false}") boolean useNewService) {
this.legacyService = legacyService;
this.newServiceClient = newServiceClient;
this.useNewService = useNewService;
}
public OrderDto getOrder(Long id) {
if (useNewService) {
return newServiceClient.getOrder(id);
}
return legacyService.getOrder(id);
}
}
Типичный порядок переезда: выбрать наименее связанный модуль → создать микросервис с тем же API → переключить трафик через Gateway → сравнить ответы → убрать код из монолита.
Когда нужен: переезд с монолита без остановки разработки.
Когда не нужен: новый проект — начинайте с нужной архитектуры сразу; монолит, который работает хорошо и не мешает — не трогайте.
Anti-Corruption Layer (ACL)
Проблема. Ваш сервис интегрируется с внешней системой, у которой своя модель данных: поля называются txn_id, amount_cents, sts, статусы передаются кодами "S"/"F"/"P". Если напрямую использовать эти модели в бизнес-логике — весь код начнёт зависеть от чужих условностей. При замене внешней системы придётся переписывать половину сервиса.
Решение. Между сервисом и внешней системой ставится слой-переводчик. Он принимает «чужие» модели и преобразует их в понятные доменные объекты — и обратно при необходимости.
// Модель внешней системы
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
) {}
// Переводчик — изолирует внешнюю модель от домена
@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);
};
}
}
Когда нужен: интеграция с внешним API или старой системой с чужой моделью данных; когда планируется замена внешней системы.
Когда не нужен: внешний API полностью совпадает с вашей моделью данных и замена не планируется.
Service Registry и Service Discovery
Проблема. Сервисы масштабируются динамически: сегодня 3 экземпляра Order Service, завтра 10. Экземпляры появляются и исчезают при деплоях, автомасштабировании, перезапусках. Прописать адреса вручную невозможно.
Решение. Service Registry — реестр, в котором каждый сервис регистрируется при запуске. Другие сервисы ищут адреса через этот реестр. Есть два подхода:
- Client-Side Discovery — клиент сам спрашивает реестр (Eureka, Consul) и выбирает экземпляр.
- Server-Side Discovery — запрос проходит через балансировщик, который знает про реестр (Kubernetes Services).
В Spring Cloud с Eureka клиент обращается к сервису по имени — реестр сам находит нужный экземпляр:
@FeignClient(name = "payment-service")
public interface PaymentServiceClient {
@PostMapping("/payments")
PaymentDto createPayment(@RequestBody CreatePaymentRequest request);
}
В 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, он встроен и ничего настраивать не нужно.
С чего начать
Если вы строите систему из нескольких сервисов:
- API Gateway + Gateway Routing — единая точка входа, маршрутизация по путям.
- Service Discovery — Kubernetes DNS, если вы в K8s; иначе Consul или Eureka.
- 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 встроен.
Что почитать дальше
- Паттерны отказоустойчивости — Circuit Breaker, Retry, Bulkhead.
- Распределённые паттерны — Saga, Outbox, Event Sourcing.
- Apache Kafka — асинхронная коммуникация между сервисами.