Membangun order-service dengan state machine CREATED hingga DONE atau CANCELLED: menyimpan order dan order_lines snapshot di PostgreSQL, endpoint checkout yang idempoten, dan emit event order.created, order.confirmed, dan order.cancelled ke event bus

Setelah auth, product, dan cart berdiri, kini tiba layanan yang paling "berotot" secara domain: order-service. Di sinilah niat belanja (cart) berubah menjadi komitmen hukum dan bisnis (order). Episode ini juga membuka fase penting: koneksi ke layanan lain lewat sinkron (validasi cart & stok) sekaligus asinkron (event untuk payment dan notification).
Mengapa order-service penting dipahami? Karena ia adalah pusat state machine — transaksi dengan banyak status yang harus konsisten. Kebanyakan bug bisnis e-commerce hidup di sini: order ganda karena retry, status tidak ter-update setelah bayar, atau cancel yang tidak sampai ke inventory. Kita akan melawan bug-bug itu dengan idempotency dan transaksi.
GET /orders/:id, GET /orders/me, cancel.TypeScript + Bun + Elysia
PostgreSQL (orders, order_items) — transaksi ACID
Kafka (emit order.created, order.confirmed, order.cancelled)
Redis (idempotency + rate limit opsional)Order menyimpan user_id sebagai referensi — tanpa join lintas layanan ke tabel users auth-service. Semua data yang harus bertahan seumur order di-snapshot (nama produk, harga, qty) ke order_items.
create table orders (
id uuid primary key default gen_random_uuid(),
user_id uuid not null,
status text not null default 'CREATED',
total numeric(12,2) not null,
idempotency_key text unique not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table order_items (
id uuid primary key default gen_random_uuid(),
order_id uuid not null references orders(id),
product_id uuid not null,
name text not null,
price numeric(12,2) not null,
qty int not null,
subtotal numeric(12,2) not null
);Perhatikan: snapshot duplikat (name, price) ke order_items bukan pelanggaran normalisasi — itu keputusan domain. Jika produk diubah namanya nanti, histori order tetap menampilkan nama saat dibeli.
CREATED → CONFIRMED → PAID → SHIPPED → DONE
│ │ │
└─────────┴────┬────┘
↓
CANCELLEDpayment.succeeded).payment.failed).Otoritas transisi dijaga ketat — state hanya boleh berpindah maju ke state legal:
const ALLOWED: Record<OrderStatus, OrderStatus[]> = {
CREATED: ['CONFIRMED', 'CANCELLED'],
CONFIRMED: ['PAID', 'CANCELLED'],
PAID: ['SHIPPED', 'CANCELLED'],
SHIPPED: ['DONE'],
DONE: [],
CANCELLED: [],
}
export function canTransition(from: OrderStatus, to: OrderStatus) {
return ALLOWED[from].includes(to)
}Transisi disimpan transaksional dengan update row dan dijalankan lewat fungsi transitionOrder(id, to) — dengan UPDATE ... WHERE status = $from RETURNING * agar dua request tidak bisa saling menimpa (optimistic concurrency).
Dua endpoint kunci:
POST /orders (dari cart — idempoten)
GET /orders/:id
GET /orders/me
POST /orders/:id/cancelUser menekan "Beli" dua kali, atau client retry otomatis karena timeout. Tanpa pengaman, dua order tercipta dengan kartu dikenakan dua kali. Solusinya idempotency key:
checkout- + user_id + timestamp), mengirim di header Idempotency-Key.export const createOrder = async ({ body, headers, set }) => {
const key = headers['idempotency-key']
if (!key) {
set.status = 400
return { error: 'missing idempotency-key' }
}
await using q = db.begin() // transaksi
const existing = await q.query('select * from orders where idempotency_key = $1', [key])
if (existing.length > 0) {
return { order: existing[0], reused: true } // aman — bukan order baru
}
const cart = await gateway.getCart(body)
// ... validasi + reserve stok (episode 5/13), hitung total, insert order + items
await q.query('insert into orders (user_id, idempotency_key, total) values ($1, $2, $3) returning *', [...])
await q.commit()
await publishOrderCreated(order) // event
set.status = 201
return { order }
}Important
Idempotency hanya kuat jika dipakai dengan transaksi: baca key, insert, dan update status dalam satu transaksi database. Kalau cek key dilakukan di luar transaksi, dua request paralel bisa lolos cek bersamaan dan membuat order ganda. Perhatikan juga bahwa unique constraint di kolom idempotency_key adalah pengaman terakhir yang tak bisa diganggu race condition.
order-service mengumumkan perubahannya lewat Kafka:
order.created — cart dikosongkan, notification bisa "order diterima".order.confirmed — payment-service mulai proses pembayaran.order.cancelled — product-service me-release stok, notification memberitahu user.await produce('order-events', [{
key: order.id,
value: JSON.stringify({
id: randomUUID(),
type: 'order.confirmed',
timestamp: new Date().toISOString(),
data: { orderId: order.id, userId: order.user_id, total: order.total },
}),
}])Episode 7 membangun order-service sebagai pusat state machine tokokita:
user_id tanpa join lintas layanan.CREATED → CONFIRMED → PAID → SHIPPED → DONE / CANCELLED dengan transisi legal.order.created, order.confirmed, order.cancelled.Di episode 8 selanjutnya, kita akan membangun payment-service — memproses pembayaran dengan mock gateway, simpan transaksi PENDING/SUCCESS/FAILED, konsumsi order.confirmed untuk memulai pembayaran, emit payment.succeeded/failed, dan memastikan retry aman dengan payment idempotency key. Sampai jumpa di episode 8!