Putting It All Together: The Place Order Flow

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Every other page in this section builds one piece of the running e-commerce domain in isolation: an aggregate, an event, an outbox row, a saga step, a read model. None of them, on its own, shows what actually happens when a single HTTP request comes in and the whole machine has to turn it into a delivered order. This page does exactly that: it assembles the Order, Inventory, Payment, and Shipping bounded contexts, the transactional outbox, the PlaceOrderSaga orchestrator, and the OrderHistoryView CQRS read model into one continuous walkthrough, twice — once for the happy path, once for the path where payment authorization fails — on the same Java 21 / Spring Boot 4.1.x / Spring Data JPA / Spring Modulith 2.x / Spring for Apache Kafka 4.1.x baseline as the rest of this section. No new pattern is introduced here and no class is redefined; every name below is exactly the one its originating page already established, cross-referenced rather than repeated.

The running domain, assembled

Strategic Design's context map names five contexts: Order as the core domain driving the checkout process, Inventory, Payment, and Shipping as supporting subdomains it depends on as a customer depends on suppliers, and Customer as a generic subdomain referenced only by CustomerId and never implemented. This page’s two walkthroughs exercise all four implemented contexts together, coordinated by the PlaceOrderSaga orchestrator Sagas already builds in full.

Diagram of the Place Order Saga assembled end to end: the Order Service’s Order aggregate and PlaceOrderService write through a local transaction into an outbox table

The complete package tree below is every class this page’s two walkthroughs touch, gathered from every prior page in this section into the one layout Structuring the Code establishes — nothing here is a new class, only a new arrangement of classes already shown in full elsewhere:

com.example.shared
├── CustomerId, ProductId, Money, Address        // value objects -- see Value Objects
└── event
    ├── DomainEvent                              // marker interface -- see Domain Events
    └── DomainEventEnvelope<T>                   // metadata envelope -- see Domain Events

com.example.order
├── domain                    // Order, OrderId, OrderLine, OrderStatus, OrderCreation, OrderRepository,
│   │                         // PricingService -- see Entities, Aggregates, Repositories/Factories/Domain Services
│   └── event                 // OrderPlaced, OrderApproved, OrderRejected, OrderCancelled -- see Domain Events
├── application                // PlaceOrderCommand, PlaceOrderService -- see Structuring the Code
├── infrastructure              // OrderJpaEntity, OrderIdConverter, OrderLineEmbeddable, OrderRepositoryImpl,
│   │                          // OutboxDomainEventPublisher, OutboxPollingRelay -- see Repositories/Factories/
│   │                          // Domain Services, Entities, Value Objects, Transactional Outbox
│   └── acl                    // anticorruption-layer translators -- see Strategic Design
├── api                        // PlaceOrderController, PlaceOrderSagaCommandHandler, OrderPlacedNotifier,
│                              // OrderPlacedProjectionHandler -- see Structuring the Code, Domain Events
├── saga                        // SagaState, SagaStatus, PlaceOrderSaga, ApproveOrderCommand, RejectOrderCommand
│                              // -- see Sagas
└── query                       // OrderHistoryView, OrderHistoryProjector, OrderHistoryQueryService -- see CQRS

com.example.inventory           // InventoryService, ReserveInventoryCommand, InventoryReserved,
                                 // InventoryReservationFailed, ReleaseInventoryCommand, InventoryReleased -- see Sagas
com.example.payment              // AuthorizePaymentCommand, PaymentAuthorized, PaymentAuthorizationFailed -- see Sagas
com.example.shipping             // ScheduleShippingCommand, ShippingScheduled -- see Sagas

PlaceOrderSagaCommandHandler is the one name on that tree not spelled out in full anywhere else — it is the inbound adapter Structuring the Code's hexagon figure already names alongside OrderController, and both walkthroughs below use it for exactly the job that figure already assigns it: receiving ApproveOrderCommand/RejectOrderCommand from PlaceOrderSaga and calling order.approve()/order.reject(reason) through the same OrderRepository and DomainEventPublisher ports PlaceOrderService already depends on, never a new port of its own.

The happy path, end to end

Placing an order that succeeds all the way through touches every layer this section has built, in the order the sequence below lays out. PlaceOrderController here is the plain, synchronous shape — validate, call PlaceOrderService, return 201 Created with a PlacedOrderResponse (CQRS) — not the publish-first variant of the same controller name Listen to Yourself shows for contrast; a service picks one shape for PlaceOrderController, not both.

  1. PlaceOrderController → PlaceOrderService. The controller builds a PlaceOrderCommand from the request body and calls PlaceOrderService.placeOrder(command) (Structuring the Code).

  2. Order.place(command). The static factory (Entities, Aggregates) builds the new Order in APPROVAL_PENDING, enforces the minimum-order-amount invariant, and returns an OrderCreation(order, List.of(new OrderPlaced(…​))).

  3. Outbox insert, same local transaction. PlaceOrderService saves the Order row through OrderRepository and hands OrderPlaced to OutboxDomainEventPublisher, which inserts one outbox row (Transactional Outbox) — the Order row and the outbox row commit or roll back together, so there is no window where one exists without the other.

  4. Relay. OutboxPollingRelay’s `@Scheduled sweep reads the unpublished row, publishes it to the order-events topic keyed by orderId, and only then marks it published — the same at-least-once relay Transactional Outbox already walks through in full.

  5. PlaceOrderSaga starts. The orchestrator reacts to OrderPlaced, persists a SagaState in RESERVING_INVENTORY, and sends ReserveInventoryCommand to Inventory (Sagas).

  6. Inventory, Payment, Shipping, in sequence. InventoryService.reserve(…​) commits its own local transaction and replies InventoryReserved; the saga advances to AUTHORIZING_PAYMENT and sends AuthorizePaymentCommand; Payment’s own handler (the same shape as InventoryService, not repeated on this page) replies PaymentAuthorized, the saga’s pivot; the saga advances to SCHEDULING_SHIPPING and sends ScheduleShippingCommand; Shipping replies ShippingScheduled.

  7. Order.approve(). The saga advances to ORDER_APPROVED and sends ApproveOrderCommand to PlaceOrderSagaCommandHandler, which loads Order via OrderRepository, calls order.approve() (Entities), saves the now-APPROVED order, and publishes OrderApproved through the same outbox as step 3.

  8. OrderHistoryProjector updates the CQRS view. The relay forwards OrderApproved the same way it forwarded OrderPlaced; OrderHistoryProjector dedups on the envelope’s eventId and applies OrderHistoryView.applyStatusChange("APPROVED", …​) (CQRS) — a client polling GET /order-history/{orderId} eventually observes status = "APPROVED", once the projector has caught up.

sequenceDiagram participant Client participant Ctrl as PlaceOrderController participant Svc as PlaceOrderService participant Order as Order aggregate participant Outbox as outbox table participant Relay as OutboxPollingRelay participant Broker as order-events topic participant Saga as PlaceOrderSaga participant Inv as InventoryService participant Pay as Payment participant Ship as Shipping participant Handler as PlaceOrderSagaCommandHandler participant Proj as OrderHistoryProjector Client->>Ctrl: POST slash orders Ctrl->>Svc: placeOrder PlaceOrderCommand Svc->>Order: Order.place command Order-->>Svc: OrderCreation order and OrderPlaced Svc->>Outbox: save order and insert outbox row, one transaction Svc-->>Ctrl: OrderCreation Ctrl-->>Client: 201 Created, PlacedOrderResponse Relay->>Outbox: read unpublished row Relay->>Broker: publish OrderPlaced keyed by orderId Broker->>Saga: deliver OrderPlaced Saga->>Inv: ReserveInventoryCommand Inv-->>Saga: InventoryReserved Saga->>Pay: AuthorizePaymentCommand Pay-->>Saga: PaymentAuthorized Saga->>Ship: ScheduleShippingCommand Ship-->>Saga: ShippingScheduled Saga->>Handler: ApproveOrderCommand Handler->>Order: order.approve Handler->>Outbox: save order and insert outbox row, one transaction Relay->>Broker: publish OrderApproved Broker->>Proj: deliver OrderApproved Proj->>Proj: dedup on eventId, then upsert OrderHistoryView status APPROVED

The payment-failure path: compensating and rejecting

The failure walkthrough shares everything up through inventory reservation with the happy path above — the divergence starts the moment Payment replies PaymentAuthorizationFailed instead of PaymentAuthorized.

  1. Steps 1-5 above run unchanged: the order is placed, the outbox relays OrderPlaced, and PlaceOrderSaga reserves inventory successfully, advancing to AUTHORIZING_PAYMENT.

  2. PaymentAuthorizationFailed. Payment’s own local transaction determines the charge cannot be authorized (an insufficient-funds decline, a fraud hold) and replies PaymentAuthorizationFailed(orderId, reason) (Sagas) instead of committing an authorization.

  3. The saga’s REJECTING_INVENTORY compensation. PlaceOrderSaga.handle(PaymentAuthorizationFailed) loads the persisted SagaState, calls state.onPaymentAuthorizationFailed(reply), which moves the state machine to REJECTING_INVENTORY and returns List.of(new ReleaseInventoryCommand(orderId)) — inventory reservation was a compensatable step, so committing it earlier is exactly what makes an explicit compensation necessary now; payment was never reached as a committed fact, so nothing about it needs undoing.

  4. Inventory releases its hold. InventoryService.release(command) runs its own local transaction, releases the reservation made in step 6 of the happy path, and replies InventoryReleased (Sagas).

  5. Order.reject(reason). The saga advances to ORDER_REJECTED and sends RejectOrderCommand(orderId, reason) to PlaceOrderSagaCommandHandler, which loads Order, calls order.reject(reason) (Entities) — moving Order from APPROVAL_PENDING straight to REJECTED, the same state-machine transition Entities's OrderStatus diagram already draws — saves the now-REJECTED order, and publishes OrderRejected through the outbox.

  6. The CQRS view reflects REJECTED. OrderHistoryProjector applies the same applyStatusChange(…​) path as the happy path’s OrderApproved, this time setting status = "REJECTED" — the read model needs no separate code path for a rejected order, because both outcomes are just a status transition applied to the same OrderHistoryView row.

sequenceDiagram participant Saga as PlaceOrderSaga participant Inv as InventoryService participant Pay as Payment participant Handler as PlaceOrderSagaCommandHandler participant Order as Order aggregate participant Outbox as outbox table participant Relay as OutboxPollingRelay participant Broker as order-events topic participant Proj as OrderHistoryProjector Note over Saga,Pay: inventory already reserved, saga in AUTHORIZING_PAYMENT Saga->>Pay: AuthorizePaymentCommand Pay-->>Saga: PaymentAuthorizationFailed Saga->>Saga: onPaymentAuthorizationFailed, state REJECTING_INVENTORY Saga->>Inv: ReleaseInventoryCommand Inv-->>Saga: InventoryReleased Saga->>Saga: state ORDER_REJECTED Saga->>Handler: RejectOrderCommand Handler->>Order: order.reject reason Handler->>Outbox: save order and insert outbox row, one transaction Relay->>Broker: publish OrderRejected Broker->>Proj: deliver OrderRejected Proj->>Proj: dedup on eventId, then upsert OrderHistoryView status REJECTED

Notice what does not appear anywhere in that diagram: no distributed transaction spans Payment’s decline and Order’s rejection, and no step ever blocks waiting for another service’s lock — Why Not Distributed Transactions's whole argument is exactly why this compensating sequence, not a rolled-back cross-service transaction, is what a multi-step failure looks like once each bounded context owns its own database.

What to test, and where it is already sketched

Testing this assembled flow does not mean standing up all four services and a broker for every test — each layer already has a narrow, fast test sketched on its own page, and the checklist below is where to find it rather than a new suite to write from scratch.

Level What the test verifies Already sketched on

Aggregate unit tests (Order)

Order.approve()/reject(reason)/cancel() only fire from APPROVAL_PENDING; removeLine enforces the minimum-order-amount invariant; two Order instances sharing an OrderId are equal regardless of status.

OrderEqualityTest on Entities, exercised against the invariant Aggregates' fuzzy-boundary scenario motivates.

Saga state-machine tests (PlaceOrderSaga / SagaState)

Given a starting SagaStatus, a specific reply transitions the state machine to the expected next status and returns the expected next command or compensation — no broker, no database, no real participant needed.

paymentFailureTriggersInventoryReleaseThenRejection on Sagas, the exact given/when/then this page’s own failure-path walkthrough follows.

Outbox / consumer idempotency tests

A redelivered envelope (the same eventId twice) is a safe no-op, not a duplicate row or a double-applied effect — the INSERT …​ ON CONFLICT DO NOTHING dedup guard returns zero affected rows on the second delivery and the handler returns immediately.

The dedup guard and OutboxPollingRelay’s at-least-once relay on Transactional Outbox, and the `processed_events guard on Domain Events.

CQRS projection tests

OrderHistoryProjector.on(…​) dispatches each event type to the right OrderHistoryView update, is idempotent under redelivery, and a query against a not-yet-caught-up view returns 409 rather than stale data.

OrderHistoryProjector’s dedup-then-`switch body and OrderHistoryQueryService.byId’s `minVersion check on CQRS.

Reading the table top to bottom also reads as the order in which a change to this flow is cheapest to verify: an aggregate-level regression is caught by the fastest, most isolated test in the list, and only a change to how the pieces are wired together — the saga’s sequencing, the outbox’s relay timing, the projector’s dispatch — needs a test that spans more than one class, never all of them running live at once for an ordinary unit-level check.

References

This page introduces no new pattern, so its references point back at the pattern-catalogue entries the assembly above depends on most directly, rather than new sources: