Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

ShopFlow — Enterprise Ecommerce Platform

Twelve Spring Boot services, an orchestrated saga across payment and inventory, Kafka for events and RabbitMQ for commands, all deployed to Kubernetes with tracing and dashboards — the project that turns the Enterprise Backend roadmap into a system you can run.

Advanced~50 hoursUpdated

Stack at a glance

Backend
Spring Boot 3.3+Java 21Spring Cloud GatewayResilience4j
Data
PostgreSQLRedisElasticsearchFlyway
Messaging
Apache KafkaRabbitMQDebeziumSchema Registry
Ops
KubernetesHelmOpenTelemetryPrometheusGrafanaTestcontainers
On this page

Most microservices tutorials stop at three services calling each other over HTTP, which is exactly the scale at which none of the hard problems appear. ShopFlow is deliberately larger: twelve services, two brokers, a distributed transaction that must not lose money, and a deployment that has to survive a rolling update.

The point is not the domain — ecommerce is a vehicle. The point is that at this size you cannot avoid the decisions the roadmap covers: where boundaries go, how a saga compensates, what happens when the payment service is slow rather than down, and how you find out which of twelve services caused the latency spike.

Key Takeaways

  • Twelve services, each owning its own database — no shared schema anywhere.
  • Order fulfilment is an orchestrated saga with explicit compensations.
  • Kafka carries events, RabbitMQ carries commands — the split is deliberate.
  • The transactional outbox makes state changes and event publication atomic.
  • Everything is observable: one trace id follows a request across all twelve.

Architecture

Twelve services behind one gateway. The order service orchestrates the saga; Kafka fans out events to consumers that need no coordination.

Each service owns its data outright. The order service cannot read the inventory database, and that constraint is what forces every interesting design decision in the project — you cannot join across services, so you must decide what to replicate, what to fetch, and what to accept as eventually consistent.

Module breakdown

ServiceOwnsHours
Common infrastructureShared DTOs, error contract, observability config8
shopflow-authUsers, JWT issuance, roles6
shopflow-productCatalogue, pricing, categories6
shopflow-cartBaskets in Redis with TTL4
shopflow-orderOrders and the saga orchestrator8
shopflow-paymentPayment intents, refunds4
shopflow-inventoryStock levels, reservations6
shopflow-searchElasticsearch projection3
shopflow-reviewReviews and ratings3
shopflow-shippingShipments, tracking3
shopflow-analyticsKafka Streams aggregations4
shopflow-notificationEmail and push3
Kubernetes deploymentHelm charts, probes, HPA, dashboards4

Build them in that order. The common module first because everything depends on the error contract and observability setup; the saga services in the middle because they are where the learning is; Kubernetes last, when there is something worth deploying.

The order saga

An orchestrated saga. The order service holds the state machine, so the whole business process is readable in one class.

Orchestration rather than choreography, because with four participants and a branch the choreographed version exists only implicitly across four services' subscriptions — and nobody can answer "what happens when an order is placed" without reading all of them.

OrderSagaOrchestrator.java
@Service
public class OrderSagaOrchestrator {
 
    @Transactional
    public void on(SagaEvent event) {
        SagaState saga = repository.lockById(event.sagaId());
 
        switch (saga.step()) {
            case STARTED -> {
                saga.advance(Step.AWAITING_PAYMENT);
                commands.send(new CapturePayment(saga.orderId(), saga.total()));
            }
            case AWAITING_PAYMENT -> {
                if (event instanceof PaymentCaptured captured) {
                    saga.recordPayment(captured.paymentId());
                    saga.advance(Step.AWAITING_STOCK);
                    commands.send(new ReserveStock(saga.orderId(), saga.lines()));
                } else {
                    // Payment was the first side effect, so nothing to compensate.
                    saga.fail("payment declined");
                    commands.send(new CancelOrder(saga.orderId()));
                }
            }
            case AWAITING_STOCK -> {
                if (event instanceof StockReserved) {
                    saga.advance(Step.AWAITING_SHIPMENT);
                    commands.send(new CreateShipment(saga.orderId()));
                } else {
                    // Compensate in reverse order of the forward steps.
                    saga.compensating();
                    commands.send(new RefundPayment(saga.orderId(), saga.paymentId()));
                }
            }
            case COMPENSATING -> {
                saga.fail("stock unavailable");
                commands.send(new CancelOrder(saga.orderId()));
            }
        }
        repository.save(saga);
    }
}

Note the ordering of the forward steps. Payment happens before stock reservation, which means an out-of-stock order requires a refund. Reversing them — reserve stock, then charge — makes the compensation a stock release instead, which is cheaper and invisible to the customer. That reordering costs nothing and is the kind of decision the project is designed to surface.

Outbox and CDC

OutboxPublisher.java
@Transactional(propagation = Propagation.MANDATORY)
public void publish(DomainEvent event) {
    // Same transaction as the state change, so the order and its event
    // commit or roll back together. MANDATORY makes calling this outside
    // a transaction a startup-visible error rather than a lost event.
    outboxRepository.save(new OutboxRecord(
            UUID.randomUUID(), event.aggregateId(),
            event.getClass().getSimpleName(), serializer.toJson(event), Instant.now()));
}

Debezium tails the PostgreSQL write-ahead log and publishes outbox rows to Kafka, so there is no relay process to operate and no polling interval. The EventRouter transform routes each row to a topic named from its aggregate type with the aggregate id as the key — which gives per-order ordering for free.

Two brokers, deliberately

Kafka carries events: OrderPlaced, PaymentCaptured, StockReserved. Many consumers care, new consumers appear over time, and analytics genuinely benefits from replaying history when a projection needs rebuilding.

RabbitMQ carries commands: send this email, generate this invoice. Exactly one handler, priority matters, and per-message acknowledgement with a dead-letter queue is the right failure model.

Running both is more operational surface than most projects need, and here it is the point — the project exists partly to make the difference concrete rather than theoretical.

Observability

otel javaagent flags
-javaagent:/opt/opentelemetry-javaagent.jar
-Dotel.service.name=shopflow-order
-Dotel.exporter.otlp.endpoint=http://otel-collector:4317
-Dotel.traces.sampler=parentbased_traceidratio
-Dotel.traces.sampler.arg=1.0

One trace id follows a request from the gateway through the order service, into the saga, across Kafka into the notification consumer, and back. When checkout is slow, the trace waterfall shows which of twelve services owns the latency — which is the single most valuable thing this setup provides.

Grafana dashboards ship with the project: golden signals per service, saga state distribution, Kafka consumer lag, and a panel showing sagas stuck in a non-terminal state, which is the failure mode that otherwise goes unnoticed.

Testing

Each service has component tests against real PostgreSQL and Kafka via Testcontainers, and contract tests with every service it talks to. The saga has its own test that drives the state machine through every branch including compensation.

There are exactly four end-to-end tests, covering checkout success, out-of-stock compensation, payment decline, and a concurrent stock race. Everything else is caught lower down, which is what keeps the suite usable.

Working through it

Fifty hours is realistic if you build rather than read. Do the common module and two services first — enough to have a real integration — then the saga, then messaging, then Kubernetes. Each phase of the Enterprise Backend roadmap maps onto a stage, and the tutorials there cover the decisions this project forces you to make.

The tempting shortcut is to build all twelve services as CRUD and add the saga last. Resist it: the saga is what shapes the boundaries, and retrofitting it means redrawing them.

What you end up with

A system that survives a rolling deploy without dropping requests, degrades rather than fails when the payment provider is slow, tells you within thirty seconds which service caused a latency spike, and whose order flow you can explain from one class. That combination is what an interview at architect level is actually probing for.