Reference architecture: event-driven microservices with Java 21, Spring Boot 3, Kafka, and Docker Compose. Demonstrates an end-to-end order → payment → notification flow using both synchronous (REST) and asynchronous (Kafka) communication, with versioned event contracts shared across services.
C4Context
title System Context — order-flow-microservices
Person(customer, "Customer", "Places an order")
System_Boundary(orderflow, "order-flow-microservices") {
System(orders, "orders-service", "Creates orders, tracks order status")
System(payments, "payments-service", "Validates and processes payment for an order")
System(notifications, "notifications-service", "Notifies the customer of the payment outcome")
}
Rel(customer, orders, "Places order via", "REST")
Rel(notifications, customer, "Notifies (simulated)", "log / webhook stub")
C4Container
title Container Diagram — order-flow-microservices
Person(customer, "Customer")
System_Boundary(orderflow, "order-flow-microservices") {
Container(orders, "orders-service", "Spring Boot 3 / Java 21", "REST API for order creation & lookup; publishes/consumes order-related events. Hexagonal layering: domain/application/adapter")
Container(payments, "payments-service", "Spring Boot 3 / Java 21", "Consumes OrderCreated; validates order synchronously; publishes payment outcome. Hexagonal layering: domain/application/adapter")
Container(notifications, "notifications-service", "Spring Boot 3 / Java 21", "Consumes payment outcome events; simulates notification dispatch. Hexagonal layering: domain/application/adapter")
ContainerDb(ordersDb, "Orders DB", "H2 (in-memory)", "Order records & status")
Container(kafka, "Kafka", "Apache Kafka (KRaft)", "Event backbone: orders.created.v1, payments.processed.v1, payments.failed.v1")
}
Rel(customer, orders, "POST /orders, GET /orders/{id}", "HTTPS/REST")
Rel(orders, ordersDb, "Reads/writes", "JDBC")
Rel(orders, kafka, "Publishes OrderCreated", "orders.created.v1")
Rel(kafka, payments, "OrderCreated", "orders.created.v1")
Rel(payments, orders, "GET /orders/{id} (validate)", "REST, sync")
Rel(payments, kafka, "Publishes PaymentProcessed/PaymentFailed", "payments.processed.v1 / payments.failed.v1")
Rel(kafka, orders, "PaymentProcessed/PaymentFailed", "updates order status")
Rel(kafka, notifications, "PaymentProcessed/PaymentFailed", "consumed")
Communication styles demonstrated:
- Synchronous (REST):
payments-servicecallsGET /orders/{id}onorders-serviceto re-validate order state before processing payment (with timeout + graceful failure iforders-serviceis unavailable). - Asynchronous (Kafka):
OrderCreated→PaymentProcessed/PaymentFailed→ notification dispatch flows entirely through Kafka topics.
Each of the 3 services (orders-service, payments-service, notifications-service) is internally organized as ports & adapters, so business logic stays framework-free and REST/Kafka/persistence concerns are isolated at the edges:
com.example.orderflow.<service>
├── domain # entities/value objects (e.g. Order, NotificationRecord) — framework-free
├── application
│ ├── port.in # inbound port interfaces (use cases), e.g. CreateOrderUseCase
│ ├── port.out # outbound port interfaces, e.g. OrderRepositoryPort, OrderEventPublisherPort
│ └── service # use-case implementations (implement port.in, depend only on port.out)
└── adapter
├── in.web # REST controllers — depend on port.in, never on a concrete use-case class
├── in.messaging # @KafkaListener classes — depend on port.in
├── out.persistence # Spring Data JPA repository + adapter implementing port.out (orders-service)
├── out.messaging # KafkaTemplate-based publisher implementing port.out
├── out.rest # RestClient-based adapter implementing port.out (payments-service only)
└── out.memory # in-memory adapter implementing port.out (notifications-service only)
Per-service inbound/outbound ports:
| Service | Inbound ports (port.in) |
Outbound ports (port.out) |
|---|---|---|
orders-service |
CreateOrderUseCase, GetOrderUseCase, ApplyPaymentOutcomeUseCase |
OrderRepositoryPort, OrderEventPublisherPort |
payments-service |
ProcessPaymentUseCase |
OrderValidationPort, PaymentEventPublisherPort |
notifications-service |
DispatchNotificationUseCase, ListNotificationsUseCase |
NotificationRecordPort |
One deliberate compromise on "pure" hexagonal purism: orders-service's Order entity keeps its JPA annotations directly in domain rather than introducing a parallel non-JPA model plus a mapping layer — at this project's scale (single aggregate, simple status transitions) a full mapper would add code without teaching an additional architectural concept beyond what the ports already demonstrate. See the design doc (linked below) for the full rationale.
This is a purely internal/structural layering — no REST endpoint, Kafka topic, event schema, or runtime behavior changed as part of introducing it.
| Module | Description |
|---|---|
contracts |
Shared event DTOs, versioned JSON Schemas, and a schema-validation utility used by all services |
orders-service |
Order creation/lookup REST API; publishes OrderCreated; consumes payment outcome events to update order status |
payments-service |
Consumes OrderCreated; validates the order synchronously against orders-service; publishes PaymentProcessed/PaymentFailed |
notifications-service |
Consumes payment outcome events; simulates notification dispatch (structured log + in-memory record, inspectable at GET /notifications) |
Requires Docker and Docker Compose. No local JDK/Maven needed — the build happens inside the Docker multi-stage build.
docker compose up --buildThis starts:
kafka(single-node, KRaft mode — no separate Zookeeper container) onlocalhost:9092orders-serviceonlocalhost:8081payments-serviceonlocalhost:8082notifications-serviceonlocalhost:8083
Optional Kafka UI (for inspecting topics/messages) via the tools profile:
docker compose --profile tools up --build
# Kafka UI at http://localhost:8080Each service exposes interactive Swagger UI and its raw OpenAPI 3 document:
| Service | Swagger UI | OpenAPI JSON |
|---|---|---|
orders-service |
http://localhost:8081/swagger-ui.html | http://localhost:8081/v3/api-docs |
payments-service |
http://localhost:8082/swagger-ui.html | http://localhost:8082/v3/api-docs |
notifications-service |
http://localhost:8083/swagger-ui.html | http://localhost:8083/v3/api-docs |
Create an order:
curl -s -X POST http://localhost:8081/orders \
-H "Content-Type: application/json" \
-d '{
"customerId": "customer-1",
"items": [
{ "sku": "SKU-1", "quantity": 2, "unitPrice": 19.99 }
]
}' | tee /tmp/order.json
ORDER_ID=$(jq -r .orderId /tmp/order.json)This triggers, asynchronously: orders-service publishes OrderCreated → payments-service consumes it, calls back into orders-service synchronously to validate, then publishes PaymentProcessed → orders-service updates the order to PAID and notifications-service dispatches a simulated success notification.
Check the order status after a moment:
curl -s http://localhost:8081/orders/$ORDER_ID
# {"orderId":"...","customerId":"customer-1","items":[...],"totalAmount":39.98,"status":"PAID"}Check dispatched notifications:
curl -s http://localhost:8083/notificationsEach event exchanged between services has:
- A versioned JSON Schema at
contracts/src/main/resources/contracts/events/<event-name>/v<n>.json - A matching Java DTO in
contracts(com.example.orderflow.contracts.events) - A Kafka topic named
<event>.v<n>(e.g.orders.created.v1)
| Event | Topic | Schema |
|---|---|---|
OrderCreated |
orders.created.v1 |
contracts/events/order-created/v1.json |
PaymentProcessed |
payments.processed.v1 |
contracts/events/payment-processed/v1.json |
PaymentFailed |
payments.failed.v1 |
contracts/events/payment-failed/v1.json |
Versioning rule: breaking changes to an event's shape are introduced as a new schema version and a new topic (e.g. orders.created.v2), so existing consumers on v1 are unaffected. All producers and consumers validate payloads against the shared schema at the service boundary via EventSchemaValidator (backed by networknt/json-schema-validator) — see contracts/src/main/java/com/example/orderflow/contracts/validation/EventSchemaValidator.java.
No schema registry is used (kept out to preserve a one-command bootstrap); compatibility is instead enforced via the shared contracts module and schema-validation unit tests (contracts/src/test/.../ContractValidationTest.java).
- Original architecture (service boundaries, Kafka vs. alternatives, sync vs. async choices, schema versioning strategy):
openspec/changes/archive/2026-07-13-order-flow-microservices/design.md - Hexagonal layering rationale (package layout, ports/adapters decisions, JPA-in-domain trade-off):
openspec/changes/hexagonal-architecture-refactor/design.md