Skip to content

Repository files navigation

order-flow-microservices

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.

Architecture

C4 — System Context

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")
Loading

C4 — Container

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")
Loading

Communication styles demonstrated:

  • Synchronous (REST): payments-service calls GET /orders/{id} on orders-service to re-validate order state before processing payment (with timeout + graceful failure if orders-service is unavailable).
  • Asynchronous (Kafka): OrderCreatedPaymentProcessed/PaymentFailed → notification dispatch flows entirely through Kafka topics.

Hexagonal Architecture

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.

Modules

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)

Running it

Requires Docker and Docker Compose. No local JDK/Maven needed — the build happens inside the Docker multi-stage build.

docker compose up --build

This starts:

  • kafka (single-node, KRaft mode — no separate Zookeeper container) on localhost:9092
  • orders-service on localhost:8081
  • payments-service on localhost:8082
  • notifications-service on localhost:8083

Optional Kafka UI (for inspecting topics/messages) via the tools profile:

docker compose --profile tools up --build
# Kafka UI at http://localhost:8080

API docs (Swagger / OpenAPI)

Each 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

Exercise the flow

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 OrderCreatedpayments-service consumes it, calls back into orders-service synchronously to validate, then publishes PaymentProcessedorders-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/notifications

Event contracts

Each event exchanged between services has:

  1. A versioned JSON Schema at contracts/src/main/resources/contracts/events/<event-name>/v<n>.json
  2. A matching Java DTO in contracts (com.example.orderflow.contracts.events)
  3. 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).

Design notes

  • 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

About

No description or website provided.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages