A production-minded, polyglot Order Management System built a distributed systems design. Four independently deployable services coordinate a multi-step business transaction — order creation, inventory reservation, payment processing, and shipment — using the Choreography Saga Pattern over Apache Kafka, with full compensation on failure.
- Saga compensation, not just happy paths — when payment fails, the system automatically releases the previously reserved inventory. No orchestrator. No shared state. Just well-designed event contracts.
- Polyglot by design — Order Service in Java 21; Inventory and Payment services in Kotlin 2.1.10. The language choice follows the team, not a rule.
- Production-grade from the start — Flyway migrations, idempotent consumers, structured observability, Helm charts for Kubernetes, and a full CI/CD pipeline. Nothing is left as a "nice to have."
- Database-per-service — each service owns its schema. No shared tables, no shared connections.
- Independently testable — every service has integration tests using real databases and real Kafka brokers via Testcontainers. No mocks at the infrastructure boundary.
The full saga spans four services and nine Kafka topics. When payment fails, a PaymentFailedEvent triggers inventory to release its reservation — restoring the system to a clean state with no central coordinator. The failure path is as well-tested as the happy path.
Every consumer maintains a processed_*_events table keyed on the event UUID. Kafka redeliveries are safely absorbed without side effects. This pattern is applied identically across all four services.
The Order Service is written in Java 21 using records, sealed types, and Spring Boot idioms. The Inventory, Payment, and Shipment services are written in Kotlin 2.1.10 using data classes, extension functions, and Kotest for expressive tests. Each language is used where it fits the team writing it.
All four services are instrumented with Micrometer Tracing + Brave and report spans to Zipkin at 100% sampling. The local environment ships with a Zipkin container — cross-service saga traces are visible out of the box.
Each service has a production-ready Helm chart with separate dev and prod value overrides, HPA configuration, liveness/readiness probes, a Kubernetes Secret for database credentials, and a ConfigMap for environment-specific settings.
GitHub Actions runs on every push and pull request: Maven build and Testcontainers integration tests for all four services, Docker image builds, and Helm lint + template dry-runs for all eight value combinations. The pipeline fails fast on any of these gates.
Kafka event payloads are defined as dedicated Java records and Kotlin data classes — never JPA entities. The persistence model never leaks into the event bus.
flowchart LR
Client["Client\n/api/orders"]
Order["Order Service\nJava 21 · :8081"]
OrderDB[("order_db")]
Inventory["Inventory Service\nKotlin · :8082"]
InvDB[("inventory_db")]
Payment["Payment Service\nKotlin · :8083"]
PayDB[("payment_db")]
Shipment["Shipment Service\nKotlin · :8084"]
ShipDB[("shipment_db")]
Kafka{{"Apache Kafka\n9 topics · 3 partitions each"}}
Zipkin["Zipkin\n:9411"]
Client --> Order
Order --- OrderDB
Inventory --- InvDB
Payment --- PayDB
Shipment --- ShipDB
Order -->|"① OrderPlacedEvent"| Kafka
Kafka -->|"② order-placed-topic"| Inventory
Inventory -->|"③ InventoryReserved / Failed"| Kafka
Kafka -->|"④ inventory-*-topic"| Order
Order -->|"⑤ OrderConfirmedEvent"| Kafka
Kafka -->|"⑥ order-confirmed-topic"| Payment
Payment -->|"⑦ PaymentSucceeded / Failed"| Kafka
Kafka -->|"⑧ payment-*-topic"| Order
Order -->|"⑨ OrderPaidEvent"| Kafka
Kafka -->|"⑩ order-paid-topic"| Shipment
Shipment -->|"⑪ ShipmentShipped / Failed"| Kafka
Kafka -->|"⑫ shipment-*-topic"| Order
Kafka -->|"payment-failed-topic\n↩ compensation"| Inventory
Order -. traces .-> Zipkin
Inventory -. traces .-> Zipkin
Payment -. traces .-> Zipkin
Shipment -. traces .-> Zipkin
| Step | Actor | Action | Outcome |
|---|---|---|---|
| 1 | Client | POST /api/orders |
— |
| 2 | Order Service | Persists order as PENDING, publishes OrderPlacedEvent |
→ order-placed-topic |
| 3 | Inventory Service | Validates stock, deducts reservation | — |
| 4 | Inventory Service | Publishes InventoryReservedEvent |
→ inventory-reserved-topic |
| 5 | Order Service | Transitions to CONFIRMED, publishes OrderConfirmedEvent |
→ order-confirmed-topic |
| 6 | Payment Service | Processes charge, publishes PaymentSucceededEvent |
→ payment-succeeded-topic |
| 7 | Order Service | Transitions to PAID |
— |
| 8 | Order Service | Publishes OrderPaidEvent |
→ order-paid-topic |
| 9 | Shipment Service | Processes shipment, publishes ShipmentShippedEvent |
→ shipment-shipped-topic |
| 10 | Order Service | Transitions to SHIPPED |
— |
| Step | Actor | Action | Outcome |
|---|---|---|---|
| 3 | Inventory Service | Insufficient stock | — |
| 4 | Inventory Service | Publishes InventoryFailedEvent |
→ inventory-failed-topic |
| 5 | Order Service | Transitions to FAILED |
— |
| Step | Actor | Action | Outcome |
|---|---|---|---|
| 6 | Payment Service | Charge declined, publishes PaymentFailedEvent |
→ payment-failed-topic |
| 7a | Order Service | Transitions to PAYMENT_FAILED |
— |
| 7b | Inventory Service | Consumes payment-failed-topic, releases reservation |
Compensation complete |
| Step | Actor | Action | Outcome |
|---|---|---|---|
| 9 | Shipment Service | Carrier rejected, publishes ShipmentFailedEvent |
→ shipment-failed-topic |
| 10 | Order Service | Transitions to SHIPMENT_FAILED |
— |
| Service | Language | Port | Database | Responsibility |
|---|---|---|---|---|
order-service |
Java 21 + Spring Boot 3.4.3 | 8081 | order_db |
REST entry point, saga orchestration state machine |
inventory-service |
Kotlin 2.1.10 + Spring Boot 3.4.3 | 8082 | inventory_db |
Stock reservation and release |
payment-service |
Kotlin 2.1.10 + Spring Boot 3.4.3 | 8083 | payment_db |
Charge processing and outcome publishing |
shipment-service |
Kotlin 2.1.10 + Spring Boot 3.4.3 | 8084 | shipment_db |
Shipment processing and outcome publishing |
.
├── docker-compose.yml # Full local stack: Postgres, Kafka, Kafka UI, Zipkin
├── docker/
│ └── postgres/init/
│ └── 01-create-databases.sql # Initialises order_db, inventory_db, payment_db, shipment_db
├── helm/
│ ├── order-service/ # Helm chart: deployment, service, HPA, secret, configmap
│ ├── inventory-service/
│ └── payment-service/
├── order-service/ # Java 21 · Spring Boot
│ ├── src/main/java/
│ └── src/main/resources/db/migration/
├── inventory-service/ # Kotlin · Spring Boot
│ ├── src/main/kotlin/
│ └── src/main/resources/db/migration/
├── payment-service/ # Kotlin · Spring Boot
│ ├── src/main/kotlin/
│ └── src/main/resources/db/migration/
└── shipment-service/ # Kotlin · Spring Boot
├── src/main/kotlin/
└── src/main/resources/db/migration/
docker compose up -dThis starts:
| Container | URL | Purpose |
|---|---|---|
| PostgreSQL | localhost:5432 |
Four logical databases (order_db, inventory_db, payment_db, shipment_db) |
| Kafka (KRaft) | localhost:9094 |
Message broker, topics auto-created on first use |
| Kafka UI | http://localhost:8080 |
Browse topics, consumer groups, and message payloads |
| Zipkin | http://localhost:9411 |
Distributed trace viewer across all four services |
# Terminal 1
cd order-service && mvn spring-boot:run
# Terminal 2
cd inventory-service && mvn spring-boot:run
# Terminal 3
cd payment-service && mvn spring-boot:run
# Terminal 4
cd shipment-service && mvn spring-boot:runcurl -s -X POST http://localhost:8082/api/inventory \
-H "Content-Type: application/json" \
-d '{"productId": 1, "quantity": 100}'curl -s -X POST http://localhost:8081/api/orders \
-H "Content-Type: application/json" \
-d '{"productId": 1, "quantity": 2, "price": 49.99}'Then:
- Kafka UI (
http://localhost:8080) — inspect events flowing through all nine topics - Zipkin (
http://localhost:9411) — view the distributed trace spanning all four services - Order status —
GET http://localhost:8081/api/orders/{id}should reachPAID
All four services have two test layers:
| Layer | Scope | Tools |
|---|---|---|
| Unit tests | Service-layer business logic in isolation | JUnit 5 / Kotest + MockK |
| Integration tests | Full consumer → service → producer flow against real infrastructure | Testcontainers (PostgreSQL 16, Kafka native 3.8.0) |
All async assertions use condition-based polling. Thread.sleep() does not appear in the test suite.
- Idempotency tested explicitly —
PaymentSagaIntegrationTestdelivers the sameOrderConfirmedEventtwice and asserts exactly one payment record is persisted. - Compensation tested explicitly —
InventorySagaIntegrationTestverifies that aPaymentFailedEventreleases a previously reserved stock quantity. - Isolated Spring contexts — the payment-service success and failure scenarios each run in their own
@SpringBootTestcontext with a unique Kafka consumer group, ensuring partition ownership and no cross-context interference.
# Run all tests (requires Docker)
cd order-service && mvn test
cd inventory-service && mvn test
cd payment-service && mvn test
cd shipment-service && mvn test
# Unit tests only (no Docker required)
cd order-service && mvn test -DskipIntegrationTests
cd inventory-service && mvn test -DskipIntegrationTests
cd payment-service && mvn test -DskipIntegrationTests
cd shipment-service && mvn test -DskipIntegrationTestsThe GitHub Actions workflow (Polyglot CI/CD) runs on every push to main/develop and on every pull request targeting main.
Pipeline stages:
- Pre-pull Testcontainers images —
postgres:16-alpineandapache/kafka-native:3.8.0are pulled before any service builds to avoid cold-start timeouts. - Build and test —
mvn clean verifyfor all four services with thetestSpring profile active. - Docker image build — each service image is built from its multi-stage
Dockerfile. - Helm lint — all eight value combinations (
dev+prod× four services) are linted. - Helm template dry-run — all eight combinations are rendered to catch template errors without a cluster.
Test reports are uploaded as artifacts on failure for post-mortem analysis.
- API Gateway — single entry point with rate limiting and routing across services