Событийно-управляемая архитектура (EDA) творит чудеса внутри периметра предприятия, развязывая микросервисы и обеспечивая беспрецедентную масштабируемость. Однако на границах системы мир по-прежнему говорит на языке синхронности. Пользователи нажимают кнопки в мобильных приложениях, IoT-датчики отправляют телеметрию, а сторонние платёжные шлюзы требуют немедленных HTTP-ответов.
В этой главе мы разберём, как построить надёжный «мост» между асинхронным внутренним миром событий и синхронным внешним миром «запросов-ответов», не нарушая при этом принципов слабой связанности и единственного источника истины.
Это тринадцатая глава цикла. Она опирается на главу I (закон Конвея, bounded context), главу II (command/event distinction), главу III (strict schemas), главу V (Interactive Queries, state stores), главу VII (RocksDB, CQRS read models), главу VIII (human-in-the-loop, паттерн одобрения) и главу X (BPC — где начинается синхронность и где заканчивается).
Прежде чем говорить об интеграции, нужно чётко разграничить три типа сообщений, которые часто путают:
«Сделай что-то». Императив. Имеет целевого получателя. Может быть отклонён. Примеры: PlaceOrder, CancelSubscription, ReserveInventory. Называется в повелительном наклонении.
«Что-то произошло». Констатация факта. Прошедшее время. Не может быть отклонён (уже случился). Примеры: OrderPlaced, SubscriptionCancelled. Уже произошёл.
«Дай мне данные». Синхронный запрос. Не изменяет состояние. Примеры: GetOrderStatus, ListCustomerOrders. Идёт через read model, не через event stream.
API принимает Command от клиента. Внутри система публикует Event в поток. Клиент читает через Query из read model.
«Отправить событие OrderRequested» — это неправильная формулировка. OrderRequested — это команда, а не событие. Событие — это OrderPlaced, которое уже произошло. Разграничение важно для корректного дизайна API: команды могут быть отклонены, события — нет.
Предоставлять внешним клиентам прямой доступ к брокеру событий (например, Apache Kafka) — плохая идея с точки зрения безопасности и архитектуры. Вместо этого используется синхронный API-шлюз, который принимает данные и конвертирует их в события.
@PostMapping("/orders")
public ResponseEntity<OrderResponse> placeOrder(
@RequestBody PlaceOrderRequest request,
@RequestHeader("Idempotency-Key") String idempotencyKey) {
// 1. Идемпотентность: проверяем, не обрабатывали ли уже
if (idempotencyStore.contains(idempotencyKey)) {
return ResponseEntity.ok(idempotencyStore.get(idempotencyKey));
}
// 2. Валидация через схему
PlaceOrderCommand command = validate(request);
// 3. Публикация команды в поток (не событие!)
producer.send(new ProducerRecord<>("order-commands", command));
// 4. Ответ клиенту: 202 Accepted + ID для отслеживания
OrderResponse response = new OrderResponse(command.getOrderId(), "PENDING");
idempotencyStore.put(idempotencyKey, response);
return ResponseEntity.accepted().body(response);
}
Ключевое: API возвращает 202 Accepted, а не 200 OK. Клиент понимает, что команда принята, но результат ещё не готов.
Клиент может повторить запрос (retry, double-click, network timeout). Без идемпотентности это создаст дублирующие команды.
Клиент генерирует UUID и передаёт в заголовке. API хранит маппинг key → response. При повторе возвращает сохранённый ответ без повторной обработки. Stripe, Adyen используют этот подход.
Если команда уже обработана — вернуть тот же результат. Через attribute_not_exists(command_id) в DynamoDB или ON CONFLICT DO NOTHING в PostgreSQL.
Если команда идемпотентна по природе (Set price = 100, а не Increment price by 100) — дубликаты не страшны. Но это редкость.
Хранить обработанные ключи N дней (например, 30). Достаточно, чтобы покрыть retry-окно клиента + запас на сетевые сбои.
Idempotency-Key должен генерироваться на клиенте, а не на API. Иначе retry сгенерирует новый ключ и создаст дубликат. Клиент генерирует UUID один раз и переиспользует его при retry.
API-шлюз — это не только routing. Это точка контроля для всех внешних запросов.
| Слой | Что проверяет | Инструменты |
|---|---|---|
| Аутентификация | Кто ты? | OAuth 2.0, JWT, mTLS, API keys |
| Авторизация | Что тебе можно? | RBAC, ABAC, OPA, scope-based |
| Rate limiting | Сколько запросов? | Token bucket, leaky bucket, sliding window |
| WAF | Это безопасный запрос? | ModSecurity, AWS WAF, Cloudflare |
| Input validation | Данные корректны? | JSON Schema, Protobuf validation |
Недостаточно «пользователь авторизован». Нужно проверить: имеет ли он право выполнить конкретную команду?
// Проверка scope для команды
@PreAuthorize("hasAuthority('orders:write')")
@PostMapping("/orders")
public ResponseEntity<?> placeOrder(...) { ... }
// Проверка владения ресурсом
if (!command.getCustomerId().equals(principal.getCustomerId())) {
throw new AccessDeniedException("Cannot place order for another customer");
}
Когда вашему микросервису нужно вызвать внешний REST API, вы вводите в детерминированный событийный процесс элемент хаоса. Внешний сервис может быть недоступен, а его ответ — измениться.
Если вы допустили ошибку в логике и решили «перемотать» поток событий назад для переработки, ваш микросервис может начать делать миллионы синхронных запросов к внешнему API. Это может привести к бану ваших IP-адресов или обрушению сервиса партнёра.
Внешний вызов — это side effect. Никогда не вызывайте внешние API во время reprocessing. Если событие требует внешнего вызова — используйте паттерн обработки с outbox: сначала сохранить команду, потом асинхронно выполнить вызов.
Если данные живут в потоках событий, как нам быстро отдать их фронтенду по REST-запросу? Ответ — CQRS (Command Query Responsibility Segregation): команды пишут через event stream, запросы читают из read model.
Command → Event → Event store. Append-only. Единственный источник истины. Не оптимизирован для чтения. Не отвечает на Query.
Материализованная проекция. Оптимизирована под конкретные запросы. Может быть денормализована. Допускает eventual consistency.
Ключевое: read model — это проекция, а не реплика. Данные не просто копируются — они фильтруются, агрегируются и пересчитываются под нужды конкретного потребителя.
// Read model для запроса «заказы клиента»
CREATE TABLE customer_orders_read_model (
customer_id UUID,
order_id UUID,
status VARCHAR(50),
total_amount DECIMAL(10,2),
created_at TIMESTAMP,
PRIMARY KEY (customer_id, created_at)
);
// Обновляется асинхронно из потока OrderEvents
// Не является источником истины
// Оптимизирован под запрос "покажи все заказы клиента X"
Состояние хранится локально на диске каждого экземпляра (RocksDB).
Плюсы: микросекундная задержка.
Минусы: сложный роутинг. Запрос должен попасть именно на тот узел, который хранит нужный ключ. Нужен «умный» балансировщик.
События потребляются и записываются в общую БД (Redis, PostgreSQL, DynamoDB).
Плюсы: любой инстанс может ответить на любой запрос. Легко масштабировать.
Минусы: сетевая задержка и стоимость транзакций.
Один микросервис (фоновый обработчик) жадно потребляет события и обновляет внешнее хранилище. Другой микросервис (REST-шлюз) читает из этого хранилища.
Плюс: изолирует сбои обработки от доступности API.
Embedded: очень низкая latency критична, ключей немного. External: простая масштабируемость, ключей много. Разделённые: критичные API должны работать независимо от обработки.
API Gateway — это не только routing. Это edge service, который объединяет данные из нескольких микросервисов для конкретного клиента.
Один API Gateway не подходит для всех. Мобильный клиент и веб-клиент имеют разные потребности:
Альтернатива REST — GraphQL federation, где клиент одним запросом собирает данные из нескольких микросервисов. Для событийного бэкенда часто удобнее: клиент запрашивает read models напрямую, без «BFF-склейки» вручную.
Самый радикальный подход — вообще отказаться от прямого изменения БД по REST-запросу. Когда пользователь нажимает «Оформить заказ», API не пишет в БД. Он создаёт команду PlaceOrder и отправляет её в поток.
Это гарантирует, что каждое действие пользователя становится частью неизменяемого журнала. Но как быть с интерфейсом? Пользователи не привыкли ждать.
Фронтенд должен быть спроектирован так, чтобы ожидать асинхронного результата. Используйте спиннеры, скелетоны, оптимистичный UI или WebSocket/SSE для push-уведомлений, когда событие будет окончательно обработано и материализовано в состояние.
Клиент отправил команду → она обрабатывается асинхронно → состояние материализуется. Это может занять секунды. Как UI должен работать с этим?
| Стратегия | Как работает | Плюсы | Минусы |
|---|---|---|---|
| Optimistic UI | Показать результат сразу, откатить при ошибке | Мгновенный отклик | Откат смущает пользователя |
| Polling | Периодически опрашивать статус команды | Простота | Нагрузка на API |
| Long polling | Держать запрос открытым, пока событие не обработается | Меньше нагрузки | Сложнее в реализации |
| WebSocket / SSE | Сервер push-ит обновление клиенту | Реалтайм, минимальная нагрузка | Сложность, state на клиенте |
// Client
const eventSource = new EventSource(`/orders/${orderId}/stream`);
eventSource.addEventListener("status", (event) => {
const data = JSON.parse(event.data);
updateOrderStatus(data.status);
});
// Server (Spring WebFlux)
@GetMapping(value = "/orders/{orderId}/stream", produces = TEXT_EVENT_STREAM_VALUE)
public Flux<OrderStatus> streamOrderStatus(@PathVariable String orderId) {
return kafkaStreams
.stream("order-events")
.filter((key, event) -> event.getOrderId().equals(orderId))
.map(this::toOrderStatus);
}
Что делать, если бизнес-процесс требует участия человека? Классический пример — вёрстка газеты или модерация контента. Дизайнер готовит макет, редактор одобряет, рекламодатель согласовывает баннеры.
В событийной архитектуре каждый шаг — это не обновление строки в БД, а публикация нового события (LayoutApprovedByEditor, AdRejectedByAdvertiser). Это позволяет:
Если ваш бэкенд разбит на независимые bounded contexts (микросервисы), то и монолитный фронтенд станет узким местом. Здесь на сцену выходят микрофронтенды — прямая реализация закона Конвея (глава I) на уровне UI.
Микрофронтенд — это независимый кусок UI, жёстко привязанный к своему бэкенд-контексту. На странице поиска отелей один микрофронтенд отвечает за карту и геолокацию (общаясь со своим сервисом поиска), другой — за блок отзывов (общаясь с сервисом ревью).
Команда отзывов может выкатить новый виджет, не пересобирая весь сайт. Это прямое следствие Team Topologies (глава I).
Если сервис отзывов упал, блок карты и бронирования продолжит работать.
Сложно обеспечить единый дизайн, когда разные команды используют свои UI-библиотеки. Решение — Design System как shared component library.
Оркестрация загрузки множества независимых модулей требует сложного роутинга на клиенте. Решение — Module Federation (Webpack 5).
| Подход | Где собирается | Инструменты |
|---|---|---|
| Client-side composition | В браузере | Module Federation, single-spa, qiankun |
| Server-side composition | На сервере | Next.js, Nuxt, Piral |
| Edge-side composition | На CDN | Edge Workers, Cloudflare Pages |
| Web Components | В браузере | Custom Elements, Shadow DOM |
Когда API генерирует команды и события, контракт между API и event schema должен быть протестирован. Иначе API изменится, а схема останется старой — или наоборот.
Любое изменение API-схемы или event-схемы должно проходить через тот же CI-пайплайн, что и код. Без автоматической проверки контрактов вы узнаете о breaking change только в production.
| Глава | Фокус | Связь с текущей главой |
|---|---|---|
| I. Среда формирует архитектуру | Закон Конвея, Team Topologies | Микрофронтенды — зеркальное отражение bounded contexts на UI |
| II. Анатомия EDA | Command vs Event, topic design | API принимает команды, публикует события |
| III. Анатомия идеального события | Strict schemas, Schema Registry | Contract testing между API и events |
| V. Механика потоковой обработки | State stores, Interactive Queries | CQRS read models для API |
| VII. Память микросервисов | RocksDB, changelog, CQRS | Три подхода к материализации состояния для чтения |
| VIII. Механика бизнес-процессов | Human-in-the-loop, Saga | Паттерн одобрения через события |
| X. Базовый шаблон BPC | BPC как минимализм | API-шлюз как BPC-сервис на границе |
| XIII. Мост между мирами | API и UI | Гибридная архитектура: async внутри, sync на границах |
202 Accepted с ID для отслеживанияПостроение событийно-управляемых микросервисов не означает отказ от синхронных API. Наоборот, грамотная интеграция позволяет использовать лучшее из обоих миров. Используйте синхронные REST/gRPC на границах системы для взаимодействия с внешним миром и UI, но внутри периметра домена конвертируйте эти действия в события. Материализуйте состояния для быстрого чтения, проектируйте асинхронные интерфейсы и применяйте микрофронтенды, чтобы ваша архитектура оставалась гибкой, масштабируемой и готовой к любым бизнес-вызовам.
Глава XIII цикла «Событийно-управляемая архитектура». Источники: Apache Kafka, Confluent, Microservices.io, Pact, Temporal, AWS Architecture Center, практические кейсы production-систем, FinCodeCow experience.
Event-Driven Architecture (EDA) works wonders inside the enterprise perimeter, decoupling microservices and providing unprecedented scalability. But at system boundaries, the world still speaks the language of synchrony. Users click buttons in mobile apps, IoT sensors send telemetry, and third-party payment gateways require immediate HTTP responses.
In this chapter, we will explore how to build a reliable bridge between the asynchronous internal world of events and the synchronous external world of request-response, without violating the principles of loose coupling and single source of truth.
This is the thirteenth chapter of the series. It builds on Chapter I (Conway's Law, bounded contexts), Chapter II (command/event distinction), Chapter III (strict schemas), Chapter V (Interactive Queries, state stores), Chapter VII (RocksDB, CQRS read models), Chapter VIII (human-in-the-loop, approval pattern), and Chapter X (BPC — where synchrony starts and ends).
"Do something." Imperative. Has a target recipient. Can be rejected. Examples: PlaceOrder, CancelSubscription. Named in imperative mood.
"Something happened." Statement of fact. Past tense. Cannot be rejected (already occurred). Examples: OrderPlaced, SubscriptionCancelled.
"Give me data." Synchronous request. Does not change state. Examples: GetOrderStatus, ListCustomerOrders. Goes through read model, not event stream.
API accepts a Command from the client. Inside, the system publishes an Event to the stream. The client reads via Query from the read model.
"Send an event OrderRequested" is a wrong formulation. OrderRequested is a command, not an event. An event is OrderPlaced, which has already happened.
@PostMapping("/orders")
public ResponseEntity<OrderResponse> placeOrder(
@RequestBody PlaceOrderRequest request,
@RequestHeader("Idempotency-Key") String idempotencyKey) {
if (idempotencyStore.contains(idempotencyKey)) {
return ResponseEntity.ok(idempotencyStore.get(idempotencyKey));
}
PlaceOrderCommand command = validate(request);
producer.send(new ProducerRecord<>("order-commands", command));
OrderResponse response = new OrderResponse(command.getOrderId(), "PENDING");
idempotencyStore.put(idempotencyKey, response);
return ResponseEntity.accepted().body(response);
}
Client generates UUID, passes in header. API stores key → response. On retry, returns stored response without re-processing.
If command already processed — return same result. Via attribute_not_exists(command_id) in DynamoDB or ON CONFLICT DO NOTHING in PostgreSQL.
If the command is idempotent by nature (Set price = 100, not Increment price by 100) — duplicates are harmless.
Store processed keys for N days (e.g., 30). Enough to cover client retry window + network failure reserve.
| Layer | What It Checks | Tools |
|---|---|---|
| Authentication | Who are you? | OAuth 2.0, JWT, mTLS, API keys |
| Authorization | What are you allowed? | RBAC, ABAC, OPA, scope-based |
| Rate Limiting | How many requests? | Token bucket, leaky bucket, sliding window |
| WAF | Is this a safe request? | ModSecurity, AWS WAF, Cloudflare |
| Input Validation | Is data valid? | JSON Schema, Protobuf validation |
If you made a logic error and decide to replay the event stream backwards, your microservice may start making millions of synchronous requests to an external API. This can lead to IP bans or crashing the partner's service.
Command → Event → Event store. Append-only. Single source of truth. Not optimized for reading.
Materialized projection. Optimized for specific queries. Can be denormalized. Eventual consistency allowed.
State lives locally on each instance (RocksDB). Microsecond latency, but complex routing.
Events consumed and written to shared DB (Redis, PostgreSQL). Easy to scale, but network latency.
One service consumes events and updates external state. Another service reads and serves API. Isolates failures.
Embedded: very low latency critical. External: simple scalability. Separated: API must work independently of processing.
Backend for Frontend (BFF) — one API Gateway doesn't fit all. Mobile and web clients have different needs:
The most radical approach is to abandon direct database writes via REST. When the user clicks "Place Order," the API does not write to the DB. It creates a PlaceOrder command and sends it to the stream.
The frontend must be designed to expect an asynchronous result. Use spinners, skeletons, optimistic UI, or WebSocket/SSE for push notifications.
| Strategy | How It Works | Pros | Cons |
|---|---|---|---|
| Optimistic UI | Show result immediately, roll back on error | Instant feedback | Rollback confuses users |
| Polling | Periodically query command status | Simplicity | API load |
| Long Polling | Keep request open until processed | Less load | Harder to implement |
| WebSocket / SSE | Server pushes updates to client | Realtime, minimal load | Complexity, client state |
Each step is not a row update, but a new event (LayoutApprovedByEditor, AdRejectedByAdvertiser). This allows:
If your backend is split into bounded contexts, monolithic frontend becomes a bottleneck. Micro-frontends — direct implementation of Conway's Law (Chapter I) at the UI level.
| Approach | Where Composed | Tools |
|---|---|---|
| Client-side composition | In browser | Module Federation, single-spa, qiankun |
| Server-side composition | On server | Next.js, Nuxt, Piral |
| Edge-side composition | On CDN | Edge Workers, Cloudflare Pages |
| Web Components | In browser | Custom Elements, Shadow DOM |
| Chapter | Focus | Link to This Chapter |
|---|---|---|
| I. The Medium Shapes the Architecture | Conway's Law, Team Topologies | Micro-frontends mirror bounded contexts at UI level |
| II. The Anatomy of EDA | Command vs Event, topic design | API accepts commands, publishes events |
| III. The Anatomy of an Ideal Event | Strict schemas, Schema Registry | Contract testing between API and events |
| V. The Mechanics of Stream Processing | State stores, Interactive Queries | CQRS read models for API |
| VII. The Memory of Microservices | RocksDB, changelog, CQRS | Three approaches to state materialization for reading |
| VIII. The Mechanics of Business Processes | Human-in-the-loop, Saga | Approval pattern via events |
| X. Basic Producer/Consumer Pattern | BPC as minimalism | API gateway as BPC service at the edge |
| XIII. A Bridge Between Worlds | API and UI | Hybrid architecture: async inside, sync at edges |
202 Accepted with ID for trackingBuilding event-driven microservices doesn't mean abandoning synchronous APIs. On the contrary, thoughtful integration lets you use the best of both worlds. Use synchronous REST/gRPC at the system's edges to interact with the outside world and UI, but inside the domain perimeter, convert these actions into events. Materialize states for fast reads, design asynchronous interfaces, and apply micro-frontends to keep your architecture flexible, scalable, and ready for any business challenges.
Chapter XIII of the "Event-Driven Architecture" series. Sources: Apache Kafka, Confluent, Microservices.io, Pact, Temporal, AWS Architecture Center, production system case studies, FinCodeCow experience.