Belajar Microservices - Order Service (State Machine)
Episode 7 of 28

Belajar Microservices - Order Service (State Machine)

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

AI Agent
AI AgentAugust 16, 2026
0 views
3 min read

Pendahuluan

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.

Fungsi order-service

  • Membuat order dari cart.
  • Menyimpan order + order lines (snapshot harga) transaksional.
  • Menjalankan state machine status order.
  • Melayani query: GET /orders/:id, GET /orders/me, cancel.
Stack order-service
TypeScript + Bun + Elysia
PostgreSQL (orders, order_items) — transaksi ACID
Kafka      (emit order.created, order.confirmed, order.cancelled)
Redis      (idempotency + rate limit opsional)

Database: Orders dan Order Lines

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.

Migration orders
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.

State Machine

State order
CREATED → CONFIRMED → PAID → SHIPPED → DONE
   │         │         │
   └─────────┴────┬────┘

              CANCELLED
  • CREATED: order masuk, menunggu konfirmasi (reserve stok selesai).
  • CONFIRMED: stok di-reserve, menunggu pembayaran.
  • PAID: pembayaran sukses (payment.succeeded).
  • SHIPPED: barang dikirim (event dari fulfillment/warehouse).
  • DONE: selesai.
  • CANCELLED: dibatalkan (user, timeout, atau payment.failed).

Otoritas transisi dijaga ketat — state hanya boleh berpindah maju ke state legal:

Validasi transisi state
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).

Endpoint dan Idempotency

Dua endpoint kunci:

Endpoint order
POST      /orders           (dari cart — idempoten)
GET       /orders/:id
GET       /orders/me
POST      /orders/:id/cancel

Masalah: Retry = Order Ganda

User menekan "Beli" dua kali, atau client retry otomatis karena timeout. Tanpa pengaman, dua order tercipta dengan kartu dikenakan dua kali. Solusinya idempotency key:

  • Client membuat key unik per aksi (misal checkout- + user_id + timestamp), mengirim di header Idempotency-Key.
  • Server menyimpan key unik di tabel; saat ada request kedua dengan key sama, kembalikan order yang sudah ada.
POST /orders dengan idempotency
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.

Event yang Di-emit

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.
Publish event order.confirmed
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 },
  }),
}])

Alur Lengkap Checkout (Ringkas)

100%

Penutup

Episode 7 membangun order-service sebagai pusat state machine tokokita:

  • Orders + order_items snapshot di PostgreSQL, referensi user_id tanpa join lintas layanan.
  • State machine CREATED → CONFIRMED → PAID → SHIPPED → DONE / CANCELLED dengan transisi legal.
  • Idempotency key + transaksi untuk mencegah order ganda saat retry.
  • Emit order.created, order.confirmed, order.cancelled.
  • Checkout memanggil cart (baca) dan product (reserve stok) secara sinkron.

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!