An order in a marketplace is more than a database row. Behind it sits a whole chain: pick the products, reserve stock, take payment, hand off to the seller, deliver, and only then close it out. And if something goes wrong — a dispute or a refund. This entire process is handled by a single service: the Order Service.
Let's take it apart from the inside: which entities are carved out, how they talk to each other, and why a dispute is a separate object rather than just a flag on the order.
What the Order Service does
Its job is to carry an order from the first "add to cart" to the final "completed." The service itself doesn't store the product catalog, doesn't charge money, and doesn't move stock — neighboring services do that. The Order Service coordinates them and holds the order's state.
Three categories of users work with it:
- Buyer — creates the order, pays, can cancel it or open a dispute.
- Seller — sees orders for their own products, marks them as shipped.
- Operator — handles disputes, can cancel any order.
The order lifecycle
An order moves through several statuses. These aren't arbitrary labels but a strict state machine — you can't jump from any status to any other.
DRAFT → PENDING_PAYMENT → PAID → SHIPPED → DELIVERED → COMPLETED
↓
EXPIRED (not paid within 15 minutes)
Additional paths:
- From
PAIDorDRAFT— cancellation intoCANCELLED/REFUNDED. - From
DELIVERED— a dispute intoDISPUTED, thenCOMPLETEDorREFUNDED.
DRAFT — the buyer has gathered products, the order isn't confirmed yet. Items can still be changed.
PENDING_PAYMENT — the order is confirmed, stock is reserved in Inventory. The buyer has 15 minutes to pay. If they don't make it, the order moves to EXPIRED.
PAID — payment went through. Now the ball is in the seller's court.
SHIPPED → DELIVERED — the seller marks the shipment, then logistics confirms handover.
COMPLETED — the order is closed. This happens automatically 14 days after delivery, if the buyer hasn't opened a dispute.
Why dispute and refund are separate entities
The first instinct is to add a disputeReason field to the orders table. But then the dispute has no lifecycle of its own: you can't track whether the seller responded, when the deadline expires, or what the operator decided.
In the Order Service, dispute and refund are carved out into separate aggregates — standalone objects with their own states.
The Dispute aggregate
The buyer can open a dispute within 14 days after delivery. The dispute lives on its own but references the order by ID.
OPEN → AWAITING_SELLER → UNDER_REVIEW → RESOLVED_FOR_BUYER
↘ RESOLVED_FOR_SELLER
The seller gets 3 days to respond. If they stay silent, the dispute moves to UNDER_REVIEW automatically. The operator reviews it and issues a decision.
An important rule: a dispute ≠ a refund. If the operator decides in the seller's favor, there's no refund — the order simply completes.
The Refund aggregate
Returning money is also a process, not an instant operation. First the reservation in Inventory has to be released, then the money returned through Payment. Each step can fail, so retries and compensations are needed.
REQUESTED → RESERVATION_RELEASED → REFUNDING → COMPLETED
↘ ↘ FAILED
A Refund is created in two cases: the buyer cancels a paid order, or a dispute is resolved in their favor. In both cases the Order waits for the RefundCompleted event and only then moves to REFUNDED.
How the aggregates coordinate
Three aggregates — three transaction boundaries. They don't call each other's methods directly; they communicate through domain events.
Example: paying for an order.
Buyer → confirms the order
Order: DRAFT → PENDING_PAYMENT, publishes OrderConfirmed
Inventory: receives OrderConfirmed → reserves stock → publishes ItemReserved
Buyer → pays
Payment → publishes PaymentSucceeded
Order: receives PaymentSucceeded → PENDING_PAYMENT → PAID
Example: a dispute with a refund.
Buyer opens a dispute → Dispute is created
Order: DELIVERED → DISPUTED
...the operator decides in the buyer's favor...
Dispute publishes DisputeResolved(buyer)
Order creates a Refund
Refund goes to Inventory (release the reservation) and Payment (return the money)
Refund publishes RefundCompleted
Order: DISPUTED → REFUNDED
Every transition is the result of an event. The aggregates don't know each other's implementation details — only the shape of the event.
How events don't get lost: the Outbox
Picture this: the Order wrote PAID to the database and then crashed before sending the event to Kafka. Inventory never found out about the order. Money is charged, there's no reservation.
To handle this, the Outbox pattern is used: the event is written in the same transaction as the change to the aggregate's state. A separate process (the relay) reads the outbox table and sends the events to Kafka. If the relay crashes, it restarts and sends again. Receivers process events idempotently (remembering the ones they've already handled).
What the order knows about prices
Prices are locked in at the moment the order is placed. If the seller raised the price an hour later, your order already holds the old price in the unitPrice field of each item. This is rule BR-O04: an item's price in an order never changes after creation.
What's stored in the database
The main tables:
orders— the order header: status, total amount, references to the reservation and the payment.order_items— the items: product, seller, quantity, price at the moment of purchase.disputes— disputes, with a logical reference to the order viaorder_id(no foreign key — different aggregates).refunds— refunds, likewise.outbox— events waiting to be sent to Kafka.
Links between aggregates go only through order_id without an FK. This is deliberate: each aggregate can be scaled, migrated, and deployed independently.
In short
- The Order Service coordinates the order but doesn't store the catalog, stock, or money — those are neighboring services.
- The order goes through a strict state machine:
DRAFT → PENDING_PAYMENT → PAID → SHIPPED → DELIVERED → COMPLETED. - Dispute and refund are separate aggregates with their own lifecycle, because they have their own logic, timeouts, and rules.
- Aggregates coordinate through domain events — no direct calls between them.
- The Outbox guarantees that the state change and the event publication are atomic.
- The price is locked in when the order is created and doesn't change.
- A dispute ≠ a refund: a dispute can be resolved in the seller's favor without any payout to the buyer.
What to read next
- How a Use Case specification is built — the format the Order Service is built from.
- DDD: aggregates and domain events — the theory behind the three-aggregate model.
- Outbox and Saga in distributed systems — how events avoid getting lost during failures.