← Back to the section

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 PAID or DRAFT — cancellation into CANCELLED / REFUNDED.
  • From DELIVERED — a dispute into DISPUTED, then COMPLETED or REFUNDED.

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 via order_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.