← 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

An order does not wait for payment forever: after fifteen minutes it goes to EXPIRED and the stock reservation is released. Without that state the goods would stay reserved for good.

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 decided for the seller

A dispute has two outcomes and both are legitimate endings: in favour of the buyer and in favour of the seller. There is no separate “rejected”, which would only add an empty third case to the reports.

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 the refund did not go through

A refund can fail, and that is not a programming error: the provider has its own reasons. FAILED is therefore an ordinary state where the investigation starts, not an exception.

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 hears OrderConfirmed, reserves stock, publishes ItemReserved buyer pays Payment publishes PaymentSucceeded Order hears PaymentSucceeded: PENDING_PAYMENT → PAID

Nobody calls anybody directly: each service hears an event and does its own part. The order becomes PAID not because it was asked to, but because the payment confirmation arrived.

Example: a dispute with a refund.

buyer opens a dispute — a Dispute is created Order DELIVERED → DISPUTED operator decides in the buyer favour Dispute publishes DisputeResolved(buyer) Order creates a Refund Refund releases the reservation and returns the money Refund publishes RefundCompleted Order DISPUTED → REFUNDED

Every transition here follows an event, not a direct call: the order never asks the refund to give the money back, it only hears that the refund went through. The price is that the state changes with a delay — in exchange, the refund can be rewritten whole without touching the order.

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.