Tạo/huỷ đơn, trạng thái đơn, transactionCreating and cancelling orders, order states, transactions
Order Service ghi đơn, dòng sản phẩm và outbox trong một transaction. Saga Orchestrator đọc sự kiện, lần lượt gọi Inventory, Payment, Shipping và chạy bước bù khi một bước lỗi. Mọi thay đổi trạng thái đi qua bảng chuyển trạng thái và cột version. Chọn “Thanh toán thất bại” để xem bước bù, hoặc “Huỷ sau khi giao vận chuyển” để xem yêu cầu bị từ chối.The Order Service writes the order, its items and an outbox row in one transaction. The Saga Orchestrator reads the event, calls Inventory, Payment and Shipping in turn, and runs compensations when a step fails. Every status change goes through the transition table and the version column. Pick “Payment fails” to see the compensation, or “Cancel after shipping” to see the request rejected.
1Người dùng 42 bấm “Đặt hàng” với 2 sản phẩm. App sinh clientOrderId một lần cho lần bấm này.User 42 taps “Place order” with 2 items. The app generates a clientOrderId once for this tap.
Mỗi trạng thái khai báo các bước kế tiếp được phép. Mọi thay đổi đi qua một hàm duy nhất kiểm tra bảng này, không cho set status tuỳ ý từ controller.Each status declares the transitions it allows. Every change goes through a single method that checks this table; no controller sets the status directly.
Người dùng bấm huỷ đúng lúc saga ghi PAID. Cột version cùng UPDATE … WHERE version = ? làm một bên nhận 0 dòng và phải đọc lại.The user cancels just as the saga writes PAID. A version column with UPDATE … WHERE version = ? makes one side see 0 rows and re-read.
Đơn, các dòng sản phẩm với giá chốt, lịch sử và outbox nằm trong một transaction. Lỗi ở bất kỳ dòng nào thì không còn gì.The order, its items with the price snapshot, the history and the outbox row share one transaction. If any row fails, nothing remains.
Kho, thanh toán, vận chuyển là các service riêng. Saga orchestration chạy từng bước, lưu tiến độ ở saga_state và chạy bù theo thứ tự ngược khi một bước lỗi.Inventory, payment and shipping are separate services. An orchestrated saga runs each step, stores progress in saga_state and compensates in reverse order when a step fails.
Không publish Kafka trong transaction DB. Ghi outbox cùng commit, relay đẩy sau; consumer xử lý idempotent theo event id.Do not publish to Kafka inside the DB transaction. Write an outbox row in the same commit and let a relay publish it; consumers deduplicate by event id.
App sinh clientOrderId một lần cho mỗi lần đặt. Unique (user_id, client_order_id) biến lần gửi thứ hai thành trả lại đơn đã có.The app generates a clientOrderId once per checkout. A unique (user_id, client_order_id) index turns the second call into returning the existing order.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
orders | id, user_id, client_order_id, status, version, total_amount, currency, created_at, updated_at | unique(user_id, client_order_id); version tăng mỗi lần đổi trạng tháiunique(user_id, client_order_id); version increments on every status change |
order_item | order_id, product_id, sku, product_name, unit_price, quantity | Giá và tên chốt lúc đặt, catalog đổi giá sau không ảnh hưởngPrice and name captured at order time; later catalog changes do not affect it |
order_history | id, order_id, from_status, to_status, reason, actor, created_at | Chỉ thêm, không sửa; phục vụ audit và CSKHAppend-only; used for audit and customer support |
outbox | id, aggregate_id, type, payload, created_at, published_at | OrderCreated, OrderPaid, OrderCancelled; relay đẩy lên order.eventsOrderCreated, OrderPaid, OrderCancelled; published to order.events by a relay |
saga_state | order_id, step, status, attempts, updated_at | RESERVE → CHARGE → SHIP; status RUNNING · COMPENSATING · COMPLETED · COMPENSATEDRESERVE → CHARGE → SHIP; status RUNNING · COMPENSATING · COMPLETED · COMPENSATED |
Huỷ đơn: chọn trạng thái đích theo quy tắc từng trạng thái, kiểm tra bảng chuyển trạng thái, cập nhật có điều kiện version, ghi lịch sử và outbox trong cùng transaction.Cancelling an order: pick the target status by per-state rules, check the transition table, update conditioned on the version, and write history and outbox in the same transaction.
@Service
@RequiredArgsConstructor
public class OrderService {
private static final Map<OrderStatus, Set<OrderStatus>> NEXT = Map.of(
PENDING, Set.of(PAID, CANCELLED),
PAID, Set.of(SHIPPED, REFUNDED),
SHIPPED, Set.of(DELIVERED),
DELIVERED, Set.of(REFUNDED),
CANCELLED, Set.of(),
REFUNDED, Set.of());
private final OrderRepository orders;
private final OrderHistoryRepository history;
private final OutboxRepository outbox;
@Transactional
public Order cancel(UUID orderId, String actor) {
Order o = orders.findById(orderId).orElseThrow(OrderNotFoundException::new);
OrderStatus from = o.getStatus();
OrderStatus to = switch (from) {
case PENDING -> CANCELLED;
case PAID -> REFUNDED; // saga refunds and releases stock
default -> throw new OrderNotCancellableException(from); // 409
};
move(o, to, actor);
outbox.save(OutboxEvent.of("OrderCancelled", o.getId(),
Map.of("from", from, "to", to)));
return o; // flush: UPDATE orders … WHERE id = ? AND version = ?
}
private void move(Order o, OrderStatus to, String actor) {
OrderStatus from = o.getStatus();
if (!NEXT.get(from).contains(to))
throw new IllegalTransitionException(from, to);
o.setStatus(to); // Order has @Version
history.save(new OrderHistory(o.getId(), from, to, actor));
}
}var next = map[Status][]Status{
Pending: {Paid, Cancelled},
Paid: {Shipped, Refunded},
Shipped: {Delivered},
Delivered: {Refunded},
}
func canMove(from, to Status) bool {
return slices.Contains(next[from], to)
}
func (s *OrderService) Cancel(ctx context.Context, id uuid.UUID, actor string) error {
return s.tx.Do(ctx, func(tx Tx) error {
o, err := tx.GetOrder(ctx, id)
if err != nil {
return err
}
var to Status
switch o.Status {
case Pending:
to = Cancelled
case Paid:
to = Refunded // saga refunds and releases stock
default:
return ErrNotCancellable // 409
}
if !canMove(o.Status, to) {
return ErrIllegalTransition
}
res, err := tx.Exec(ctx, `UPDATE orders SET status = $1, version = version + 1
WHERE id = $2 AND version = $3`, to, id, o.Version)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrConcurrentUpdate // someone changed it first
}
if err := tx.InsertHistory(ctx, id, o.Status, to, actor); err != nil {
return err
}
return tx.InsertOutbox(ctx, "OrderCancelled", id, map[string]any{"from": o.Status, "to": to})
})
}| OrchestrationOrchestration | ChoreographyChoreography | |
|---|---|---|
| Luồng nằm ở đâuWhere the flow lives | Một orchestrator giữ thứ tự bước và bước bùOne orchestrator holds the step order and compensations | Rải rác trong các service, mỗi service nghe sự kiện của service khácSpread across services, each listening to the others' events |
| Theo dõi, gỡ lỗiTracing and debugging | Đọc saga_state là biết đơn đang ở bước nàoReading saga_state shows which step an order is in | Phải ghép sự kiện từ nhiều serviceEvents from several services must be stitched together |
| Phụ thuộcCoupling | Orchestrator biết API của mọi bênThe orchestrator knows every participant's API | Service độc lập hơn, nhưng dễ sinh vòng sự kiệnServices are more independent, but event cycles creep in |
| Chọn khiPick it when | Từ 3 bước trở lên, có bước bù rõ ràngThree or more steps with clear compensations | 2 bước đơn giản, các team muốn tự chủTwo simple steps and autonomous teams |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Giá trong catalog đổi theo thời gian. Hoá đơn, hoàn tiền và đối soát phải dựa trên giá lúc khách đặt, nên đơn giữ bản sao giá và tên sản phẩm.Catalog prices change over time. Invoices, refunds and reconciliation must use the price at order time, so the order keeps its own copy of price and name.
Cả hai đọc version 0. Bên commit trước tăng lên 1; bên sau UPDATE … WHERE version = 0 nhận 0 dòng, đọc lại đơn và áp quy tắc theo trạng thái mới, ví dụ PAID thì chuyển sang luồng hoàn tiền.Both read version 0. The first commit moves it to 1; the other UPDATE … WHERE version = 0 hits 0 rows, re-reads the order and applies the rule for the new status, e.g. PAID goes to the refund flow.
Rollback xoá như chưa từng xảy ra trong một DB. Bước bù là một nghiệp vụ mới làm ngược tác dụng (trả hàng về kho, hoàn tiền), để lại dấu vết và phải idempotent vì có thể chạy lại.A rollback undoes work inside one DB as if it never happened. A compensation is a new business action that reverses the effect (release stock, refund), leaves a trace and must be idempotent because it may run again.
Client sinh clientOrderId trước khi gửi và dùng lại khi retry. DB có unique (user_id, client_order_id); lần thứ hai bắt lỗi unique và trả lại đơn đã tạo với cùng kết quả.The client generates a clientOrderId before sending and reuses it on retry. The DB has unique (user_id, client_order_id); the second call catches the unique violation and returns the existing order with the same result.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.