Progress belajar
Modul 43 dari 73
0% 0/73 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
Inventory Domain
Cegah Overselling
Inventory adalah pagar terakhir yang memastikan order sah, stok konsisten, dan toko tidak pernah menjual serum yang sebenarnya sudah habis, walau ribuan checkout datang dalam detik yang sama.
Kenapa Inventory Mudah Salah?
Masalahnya bukan menyimpan angka stok, tetapi menjaga angka itu tetap benar di bawah beban checkout bersamaan.
Di React, cart adalah state UI yang konfliknya selesai dengan render ulang. Di backend, inventory adalah state bisnis yang harus tetap benar walau banyak request menulis baris yang sama pada milidetik yang sama.
Di modul cart, kita sengaja tidak menyimpan harga di cart karena cart hanya niat beli. Di modul checkout, kita membuat order dan snapshot harga. Di modul inventory ini, kita menjawab pertanyaan paling mahal di sebuah online shop: bagaimana kalau dua customer membeli varian terakhir Serum Niacinamide 10% yang sama dalam detik yang sama? Jika jawabannya salah, stok jadi minus, satu order harus dibatalkan manual, dan satu customer kecewa.
Kata kuncinya adalah overselling, yaitu menjual lebih banyak dari yang benar-benar ada. Overselling hampir tidak pernah muncul di laptop kita karena di lokal request datang satu per satu. Ia muncul justru saat traffic naik, persis di momen flash sale yang seharusnya jadi momen terbaik bisnis.
Di React, dua komponen yang menulis state yang sama diselesaikan oleh React dengan re-render. Di inventory, dua transaksi yang menulis baris stok yang sama harus diselesaikan oleh database, karena efeknya nyata dan permanen: angka di tabel benar-benar berubah, dan tidak ada tombol undo.
DB::transaction()membungkus blok, tetapi sering menyembunyikan detail lock, urutan query, dan error mapping.- Eloquent membuat
$variant->decrement('stock')terlihat sepele, padahal concurrency tetap perlu desainlockForUpdate()yang eksplisit.
- Service menerima
context.Context, memulai transaksipool.Begin, lalu repository menjalankan SQL yang terbaca jelas. SELECT ... FOR UPDATEmembuat satu baris inventory diproses bergiliran, dan kita yang memegang kendali transaksinya.
Inventory yang sehat tidak hanya punya kolom angka. Ia punya riwayat alasan perubahan (movement log), aturan reservasi dengan batas waktu, aturan release, dan mekanisme lock saat checkout. PostgreSQL mendokumentasikan bahwa SELECT ... FOR UPDATE mengunci baris terpilih terhadap update concurrent, dan lock baris itu dilepas saat transaksi selesai (commit atau rollback). Lihat PostgreSQL SELECT dan PostgreSQL Explicit Locking.
Tiga Angka Stok: Available, Reserved, Sold
Pisahkan stok berdasarkan status bisnisnya, bukan menyimpan satu angka total.
Satu kolom stock saja tidak cukup. Inventory yang bisa diaudit memisahkan stok menjadi tiga angka: yang siap dialokasikan, yang sedang menunggu pembayaran, dan yang sudah terjual.
Jumlah stok yang belum reserved dan belum sold, yaitu stok yang masih boleh dijanjikan ke order baru. Jumlah yang benar-benar boleh dijual adalah available_stock - safety_stock.
Jumlah stok yang sudah ditahan untuk order pending_payment, tetapi belum menjadi penjualan final. Ini adalah janji sementara yang bisa batal.
Jumlah stok yang sudah dibayar dan dikonfirmasi sebagai penjualan, biasanya setelah payment gateway mengirim webhook success.
Kunci yang sering keliru: checkout TIDAK langsung menambah sold_stock. Checkout memindahkan stok dari available ke reserved. Yang membuat stok benar-benar sold adalah pembayaran. Pemisahan ini yang menahan overselling tanpa membuat customer yang sudah klik checkout kehilangan haknya atas stok.
Checkout
available_stock turun, reserved_stock naik. Customer belum membayar, jadi belum boleh masuk sold_stock.
Payment success
reserved_stock turun, sold_stock naik. Inilah satu-satunya momen stok dinyatakan benar-benar terjual.
Cancel atau timeout
reserved_stock turun, available_stock naik kembali. Stok dilepas agar bisa dibeli customer lain.
stateDiagram-v2
[*] --> Available: admin restock
Available --> Reserved: checkout (pending_payment)
Reserved --> Sold: payment success
Reserved --> Available: cancel / payment timeout
Sold --> [*]: shipment diproses
note right of Available
available_stock = stok belum reserved
safety_stock tetap dilindungi
end note
note right of Reserved
reserved_stock milik order pending_payment
wajib punya batas waktu (expiry)
end noteGambar 1. Siklus state stok dari restock, checkout, pembayaran, sampai pengiriman. Reserved punya dua jalan keluar: menjadi Sold atau kembali ke Available.
available + reserved + sold adalah model stok di aplikasi, bukan janji jumlah fisik gudang yang selalu cocok detik per detik. Selisih karena retur, produk rusak, atau salah hitung gudang diselesaikan lewat manual adjustment yang juga tercatat sebagai movement, sehingga model dan fisik bisa direkonsiliasi.
Skema dan Stock Movement Log
Setiap perubahan stok harus meninggalkan jejak: kapan, untuk order apa, dari berapa ke berapa, dan kenapa.
Tanpa movement log, bug inventory tidak bisa dibuktikan. Dengan movement log, setiap mutasi stok punya audit trail yang menjelaskan dirinya sendiri.
Di Laravel, ini mirip membuat tabel stock_movements daripada hanya menimpa kolom stock di model ProductVariant. Bedanya, di Go kita membuat repository method yang eksplisit untuk tiap transisi stok (ReserveStock, ConfirmSold, ReleaseReservation) agar business rule tidak tersebar ke banyak tempat.
Perhatikan tipe kolom: stok adalah hitungan unit, jadi INTEGER cukup dan tepat. Uang TIDAK ada di tabel ini, harga di-snapshot di order_items (modul checkout) dengan tipe domain PriceRupiah int64 dan kolom BIGINT. Memisahkan unit dari rupiah membuat model lebih jujur.
db/migrations/021_create_inventories.up.sqlCREATE TABLE inventories ( product_variant_id BIGINT PRIMARY KEY REFERENCES product_variants(id), available_stock INTEGER NOT NULL DEFAULT 0, reserved_stock INTEGER NOT NULL DEFAULT 0, sold_stock INTEGER NOT NULL DEFAULT 0, safety_stock INTEGER NOT NULL DEFAULT 0, version BIGINT NOT NULL DEFAULT 1, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CHECK (available_stock >= 0), CHECK (reserved_stock >= 0), CHECK (sold_stock >= 0), CHECK (safety_stock >= 0) ); CREATE TABLE inventory_movements ( id BIGSERIAL PRIMARY KEY, product_variant_id BIGINT NOT NULL REFERENCES product_variants(id), order_id BIGINT REFERENCES orders(id), movement_type TEXT NOT NULL CHECK (movement_type IN ( 'reserve', 'confirm_sold', 'release_reservation', 'manual_adjustment', 'restock' )), quantity INTEGER NOT NULL CHECK (quantity > 0), from_available INTEGER NOT NULL, to_available INTEGER NOT NULL, from_reserved INTEGER NOT NULL, to_reserved INTEGER NOT NULL, from_sold INTEGER NOT NULL, to_sold INTEGER NOT NULL, reason TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_inventory_movements_variant_created_at ON inventory_movements (product_variant_id, created_at DESC); CREATE INDEX idx_inventory_movements_order_id ON inventory_movements (order_id);
CHECK (available_stock >= 0) adalah jaring pengaman terakhir di tingkat database. Bahkan jika ada bug logika di Go yang lolos sampai ke UPDATE, constraint ini akan menolak commit yang membuat stok minus dengan error, bukan diam-diam merusak data. Kolom from_* dan to_* menyimpan snapshot sebelum dan sesudah tiap mutasi, sehingga movement log bisa dibaca seperti rekening koran.
Tulis movement log di transaksi yang sama dengan perubahan inventory. Kalau stok berubah tetapi log gagal ditulis (atau sebaliknya), transaksi harus rollback. Audit trail yang bolong lebih berbahaya daripada tidak ada audit trail, karena ia berbohong.
internal/inventory/model.gopackage inventory import "time" type Inventory struct { ProductVariantID int64 AvailableStock int32 ReservedStock int32 SoldStock int32 SafetyStock int32 Version int64 UpdatedAt time.Time } type MovementType string const ( MovementReserve MovementType = "reserve" MovementConfirmSold MovementType = "confirm_sold" MovementReleaseReservation MovementType = "release_reservation" MovementManualAdjustment MovementType = "manual_adjustment" MovementRestock MovementType = "restock" ) type Movement struct { ProductVariantID int64 OrderID *int64 // pointer: NULL untuk restock/adjustment tanpa order Type MovementType Quantity int32 FromAvailable int32 ToAvailable int32 FromReserved int32 ToReserved int32 FromSold int32 ToSold int32 Reason string }
order_id boleh kosong untuk restock dan adjustment yang bukan dari order. Di PHP itu cukup ?int $orderId. Di Go, nullable diwakili pointer *int64: nil berarti tidak ada order, sedangkan *int64 yang menunjuk ke 9001 berarti ada. pgx memetakan nil ke NULL dan sebaliknya secara otomatis.
Reservasi Saat Checkout
Checkout tidak membuat stok sold. Checkout membuat reservasi untuk order pending_payment.
Reservasi adalah janji sementara: stok ditahan untuk satu order tertentu sampai customer membayar atau waktu pembayaran habis.
Saat POST /v1/orders, service checkout membaca cart, validasi produk aktif, validasi stok, membuat order, menyimpan order items dengan snapshot harga, lalu reserve stok untuk tiap item. Semua langkah ini harus berjalan dalam SATU transaksi database. Di pgx v5, pool.Begin(ctx) (atau pool.BeginTx untuk opsi isolation) memulai transaksi, dan dokumentasi pgx menegaskan setiap transaksi wajib ditutup dengan Commit atau Rollback. Lihat pgxpool BeginTx.
sequenceDiagram
participant FE as Frontend React
participant API as Go API (chi)
participant SVC as Order Service
participant DB as PostgreSQL
FE->>API: POST /v1/orders
API->>SVC: Checkout(ctx, userID)
SVC->>DB: BEGIN
SVC->>DB: SELECT cart_items + product_variants
SVC->>DB: INSERT orders (status pending_payment, reservation_expires_at)
loop tiap item (variant diurutkan menaik)
SVC->>DB: SELECT inventory FOR UPDATE
SVC->>DB: UPDATE available -= qty, reserved += qty
SVC->>DB: INSERT inventory_movements (reserve)
SVC->>DB: INSERT order_items (snapshot harga)
end
SVC->>DB: DELETE cart_items
SVC->>DB: COMMIT
API-->>FE: 201 Created + order_numberGambar 2. Alur checkout dari POST /v1/orders sampai reservasi stok. Semua di dalam satu BEGIN…COMMIT, varian dikunci satu per satu dengan urutan menaik.
Service-lah pemilik transaksi. Repository inventory menerima pgx.Tx, bukan pool, sehingga reservasi stok ikut dalam transaksi yang sama dengan pembuatan order. Pola ini (“transaction owner di service, repository menerima Tx”) sudah kita pakai sejak Roadmap 3.
internal/order/checkout.gopackage order import ( "context" "fmt" "sort" "github.com/jackc/pgx/v5/pgxpool" "github.com/kamu/skincare-backend/internal/inventory" ) type CheckoutService struct { pool *pgxpool.Pool cartRepo CartRepository orderRepo OrderRepository inventoryRepo InventoryRepository } func (s *CheckoutService) Checkout(ctx context.Context, userID int64) (*Order, error) { tx, err := s.pool.Begin(ctx) if err != nil { return nil, fmt.Errorf("begin checkout tx: %w", err) } defer tx.Rollback(ctx) // no-op kalau sudah Commit; aman dipanggil ganda items, err := s.cartRepo.ListItemsForCheckout(ctx, tx, userID) if err != nil { return nil, fmt.Errorf("list checkout items: %w", err) } if len(items) == 0 { return nil, ErrEmptyCart } // Urutkan variant menaik supaya semua transaksi mengunci baris // dengan urutan sama. Ini mencegah deadlock saat dua order // menyentuh dua variant yang sama dalam urutan berbeda. sort.Slice(items, func(i, j int) bool { return items[i].ProductVariantID < items[j].ProductVariantID }) order, err := s.orderRepo.CreatePendingPayment(ctx, tx, userID) if err != nil { return nil, fmt.Errorf("create pending order: %w", err) } for _, item := range items { err := s.inventoryRepo.ReserveStock(ctx, tx, inventory.ReserveStockParams{ ProductVariantID: item.ProductVariantID, OrderID: order.ID, Quantity: item.Quantity, Reason: "checkout", }) if err != nil { return nil, fmt.Errorf("reserve stock variant %d: %w", item.ProductVariantID, err) } if err := s.orderRepo.CreateItemSnapshot(ctx, tx, order.ID, item); err != nil { return nil, fmt.Errorf("create order item snapshot: %w", err) } } if err := s.cartRepo.ClearCart(ctx, tx, userID); err != nil { return nil, fmt.Errorf("clear cart: %w", err) } if err := tx.Commit(ctx); err != nil { return nil, fmt.Errorf("commit checkout tx: %w", err) } return order, nil }
Jangan commit setelah order dibuat lalu reserve stok di transaksi baru. Kalau reserve gagal, order sudah telanjur ada tanpa stok yang valid, dan customer melihat order yang sebenarnya tidak punya barang. Order, order items, reservasi stok, dan movement log harus atomic: semua berhasil, atau semua batal.
Saat satu varian kehabisan stok, ReserveStock mengembalikan ErrInsufficientStock. Itu kondisi bisnis yang wajar, bukan kerusakan server. Handler memetakannya ke 409 Conflict (atau 422) dengan pesan yang ramah, bukan 500. Transaksi otomatis rollback lewat defer tx.Rollback.
Row Locking untuk Concurrent Checkout
Kunci baris inventory yang sama agar dua transaksi tidak menghitung stok dari angka yang sudah basi.
Overselling terjadi ketika dua transaksi membaca stok yang sama sebelum salah satunya sempat menulis update. Keduanya melihat angka lama, keduanya lolos validasi, keduanya menulis. Stok pun minus.
Bayangkan available_stock = 1 dan dua customer membeli 1 item varian yang sama persis pada saat bersamaan. Tanpa lock, urutan kejadiannya bisa seperti ini: T1 membaca 1, T2 membaca 1, T1 cek 1 >= 1 lolos, T2 cek 1 >= 1 juga lolos, T1 tulis 0, T2 tulis 0. Hasilnya dua reservasi dari stok satu. Itulah overselling.
Dengan SELECT ... FOR UPDATE, baris dikunci sejak dibaca. Transaksi kedua yang mencoba mengunci baris yang sama akan MENUNGGU sampai transaksi pertama commit atau rollback, lalu baru membaca angka terbaru. T2 kini melihat 0, validasi gagal, dan mengembalikan ErrInsufficientStock. Tepat satu reservasi yang lolos.
sequenceDiagram participant T1 as Checkout A participant T2 as Checkout B participant DB as Row inventory (variant 101) T1->>DB: BEGIN; SELECT ... FOR UPDATE (kunci baris) Note over T2,DB: T2 mencoba mengunci baris yang sama T2->>DB: BEGIN; SELECT ... FOR UPDATE (menunggu) T1->>DB: available 1 -> 0, reserved 0 -> 1 T1->>DB: COMMIT (lock dilepas) DB-->>T2: lock didapat, baca available = 0 T2->>DB: cek 0 >= 1 GAGAL -> ErrInsufficientStock T2->>DB: ROLLBACK
Gambar 3. Dua checkout berebut baris yang sama. FOR UPDATE membuat keduanya bergiliran, sehingga tepat satu yang berhasil reserve.
Di JavaScript, race condition muncul saat dua promise menulis state yang sama. Refleks pertama developer Go pemula adalah memakai sync.Mutex. Tapi mutex hanya melindungi satu proses. Di production API berjalan di banyak container (mis. beberapa task ECS), dan mutex di container A tidak tahu apa-apa soal container B. Lock yang benar harus ada di tempat yang dilihat semua proses: database.
internal/inventory/repository.gopackage inventory import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" ) var ( ErrInventoryNotFound = errors.New("inventory not found") ErrInsufficientStock = errors.New("insufficient stock") ErrInvalidQuantity = errors.New("invalid quantity") ) type ReserveStockParams struct { ProductVariantID int64 OrderID int64 Quantity int32 Reason string } type Repository struct{} func (r *Repository) ReserveStock(ctx context.Context, tx pgx.Tx, p ReserveStockParams) error { if p.Quantity <= 0 { return ErrInvalidQuantity } inv, err := r.lockInventory(ctx, tx, p.ProductVariantID) if err != nil { return err } sellable := inv.AvailableStock - inv.SafetyStock if sellable < p.Quantity { return fmt.Errorf("%w: need %d, sellable %d", ErrInsufficientStock, p.Quantity, sellable) } nextAvailable := inv.AvailableStock - p.Quantity nextReserved := inv.ReservedStock + p.Quantity _, err = tx.Exec(ctx, ` UPDATE inventories SET available_stock = $2, reserved_stock = $3, version = version + 1, updated_at = NOW() WHERE product_variant_id = $1 `, p.ProductVariantID, nextAvailable, nextReserved) if err != nil { return fmt.Errorf("update reservation: %w", err) } return r.insertMovement(ctx, tx, movementRow{ variantID: p.ProductVariantID, orderID: &p.OrderID, kind: MovementReserve, quantity: p.Quantity, from: inv, toAvail: nextAvailable, toReserved: nextReserved, toSold: inv.SoldStock, reason: p.Reason, }) } func (r *Repository) lockInventory(ctx context.Context, tx pgx.Tx, variantID int64) (Inventory, error) { var inv Inventory err := tx.QueryRow(ctx, ` SELECT product_variant_id, available_stock, reserved_stock, sold_stock, safety_stock, version, updated_at FROM inventories WHERE product_variant_id = $1 FOR UPDATE `, variantID).Scan( &inv.ProductVariantID, &inv.AvailableStock, &inv.ReservedStock, &inv.SoldStock, &inv.SafetyStock, &inv.Version, &inv.UpdatedAt, ) if errors.Is(err, pgx.ErrNoRows) { return Inventory{}, ErrInventoryNotFound } if err != nil { return Inventory{}, fmt.Errorf("lock inventory: %w", err) } return inv, nil }
Karena tiga transisi stok (reserve, confirm_sold, release_reservation) menulis movement dengan bentuk yang sama, kita satukan ke satu helper agar tidak ada SQL INSERT yang disalin tiga kali (dan berisiko beda satu kolom).
internal/inventory/movement.gopackage inventory import ( "context" "fmt" "github.com/jackc/pgx/v5" ) type movementRow struct { variantID int64 orderID *int64 kind MovementType quantity int32 from Inventory toAvail int32 toReserved int32 toSold int32 reason string } func (r *Repository) insertMovement(ctx context.Context, tx pgx.Tx, m movementRow) error { _, err := tx.Exec(ctx, ` INSERT INTO inventory_movements ( product_variant_id, order_id, movement_type, quantity, from_available, to_available, from_reserved, to_reserved, from_sold, to_sold, reason ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `, m.variantID, m.orderID, m.kind, m.quantity, m.from.AvailableStock, m.toAvail, m.from.ReservedStock, m.toReserved, m.from.SoldStock, m.toSold, m.reason, ) if err != nil { return fmt.Errorf("insert %s movement: %w", m.kind, err) } return nil }
- Membaca stok lalu update bisa disisipi transaksi lain di antaranya.
- Validasi memakai angka yang sudah basi, dua-duanya lolos.
- Baris dikunci sejak dibaca sampai commit atau rollback.
- Transaksi kedua membaca angka terbaru, jadi validasinya jujur.
Kalau order A mengunci variant 5 lalu 9, dan order B mengunci variant 9 lalu 5, keduanya bisa saling menunggu selamanya: deadlock. PostgreSQL akan mendeteksi dan membatalkan salah satunya, tapi itu error yang seharusnya bisa dihindari. Solusinya ada di checkout.go: sort daftar item berdasarkan ProductVariantID menaik sebelum mengunci, sehingga semua transaksi mengunci dengan urutan yang sama.
Atomic UPDATE vs SELECT FOR UPDATE
Dua cara mencegah oversell, dan kenapa modul ini memilih lock eksplisit.
FOR UPDATE bukan satu-satunya cara. Untuk kasus paling sederhana, satu UPDATE kondisional sudah cukup aman. Memahami kapan masing-masing cocok membuat desainmu sadar, bukan asal ikut pola.
Cara pertama, atomic conditional UPDATE, menyatukan cek dan tulis dalam satu pernyataan. Database mengevaluasi WHERE dan menulis dalam satu langkah yang tak bisa disisipi, lalu kita periksa berapa baris yang terpengaruh.
Atomic UPDATE: cek dan tulis sekali jalanUPDATE inventories SET available_stock = available_stock - $2, reserved_stock = reserved_stock + $2, version = version + 1, updated_at = NOW() WHERE product_variant_id = $1 AND available_stock - safety_stock >= $2; -- RowsAffected == 0 berarti stok tidak cukup
internal/inventory/reserve_atomic.gopackage inventory import ( "context" "fmt" "github.com/jackc/pgx/v5" ) // ReserveStockAtomic: varian tanpa SELECT terpisah. Cocok kalau kita // tidak perlu snapshot before/after di luar movement log. func (r *Repository) ReserveStockAtomic(ctx context.Context, tx pgx.Tx, p ReserveStockParams) error { if p.Quantity <= 0 { return ErrInvalidQuantity } tag, err := tx.Exec(ctx, ` UPDATE inventories SET available_stock = available_stock - $2, reserved_stock = reserved_stock + $2, version = version + 1, updated_at = NOW() WHERE product_variant_id = $1 AND available_stock - safety_stock >= $2 `, p.ProductVariantID, p.Quantity) if err != nil { return fmt.Errorf("atomic reserve: %w", err) } if tag.RowsAffected() == 0 { return ErrInsufficientStock // stok kurang ATAU baris tidak ada } return nil }
- Satu round-trip, paling ringkas dan cepat.
- Tidak punya snapshot before/after di Go sebelum menulis.
RowsAffected == 0ambigu: bisa stok kurang, bisa baris hilang.
- Kita pegang nilai sebelum dan sesudah untuk movement log yang kaya.
- Bisa menjalankan beberapa validasi bisnis di Go di antara read dan write.
- Satu round-trip ekstra, tetapi paling jelas dibaca dan diaudit.
Modul ini memilih SELECT ... FOR UPDATE karena movement log butuh from_* dan to_* yang tepat, dan satu reservasi bisa menyentuh beberapa varian dengan validasi bisnis lain (produk aktif, batas per-order). Untuk dekrement stok tunggal yang sederhana tanpa kebutuhan snapshot, atomic UPDATE lebih ringkas dan sama amannya.
Ada varian ketiga: FOR UPDATE SKIP LOCKED, yang melewati baris yang sedang dikunci alih-alih menunggu. Itu BUKAN untuk checkout (kita justru ingin menunggu giliran), tetapi sangat cocok untuk worker yang memproses banyak order kadaluarsa secara paralel tanpa saling menabrak. Kita pakai polanya di section release expiry.
Payment Success: Reserved Menjadi Sold
Stok yang dibayar pindah dari reserved ke sold, bukan dari available ke sold.
Payment success TIDAK boleh mengurangi available_stock lagi, karena stok sudah ditahan sejak checkout. Yang berubah hanya status janji: dari reserved menjadi sold.
Saat payment gateway mengirim webhook success, service payment membaca order pending_payment, lalu memanggil ConfirmSold untuk tiap order item, semuanya dalam transaksi yang sama dengan update status order menjadi paid. Kalau salah satu gagal, seluruh konfirmasi rollback, dan webhook bisa diulang dengan aman.
internal/inventory/confirm_sold.gopackage inventory import ( "context" "fmt" "github.com/jackc/pgx/v5" ) type ConfirmSoldParams struct { ProductVariantID int64 OrderID int64 Quantity int32 Reason string } func (r *Repository) ConfirmSold(ctx context.Context, tx pgx.Tx, p ConfirmSoldParams) error { if p.Quantity <= 0 { return ErrInvalidQuantity } inv, err := r.lockInventory(ctx, tx, p.ProductVariantID) if err != nil { return err } if inv.ReservedStock < p.Quantity { return fmt.Errorf("%w: reserved %d, need %d", ErrInsufficientStock, inv.ReservedStock, p.Quantity) } nextReserved := inv.ReservedStock - p.Quantity nextSold := inv.SoldStock + p.Quantity _, err = tx.Exec(ctx, ` UPDATE inventories SET reserved_stock = $2, sold_stock = $3, version = version + 1, updated_at = NOW() WHERE product_variant_id = $1 `, p.ProductVariantID, nextReserved, nextSold) if err != nil { return fmt.Errorf("update sold stock: %w", err) } return r.insertMovement(ctx, tx, movementRow{ variantID: p.ProductVariantID, orderID: &p.OrderID, kind: MovementConfirmSold, quantity: p.Quantity, from: inv, toAvail: inv.AvailableStock, // available tidak berubah toReserved: nextReserved, toSold: nextSold, reason: p.Reason, }) }
Payment gateway sering mengirim webhook yang sama dua kali (at-least-once delivery). Tanpa pengaman, ConfirmSold bisa jalan dua kali dan reserved_stock berkurang dua kali, lagi-lagi membuat angka minus. Konfirmasi stok harus idempotent.
Cara paling kokoh membuat idempotent BUKAN dengan mengecek di Go, melainkan menyandarkan pada transisi status order yang dijaga database. Service payment hanya boleh memindahkan order dari pending_payment ke paid satu kali. UPDATE ... WHERE status = 'pending_payment' yang menghasilkan nol baris berarti order sudah paid sebelumnya, jadi webhook duplikat berhenti sebelum menyentuh stok.
internal/payment/webhook.gopackage payment import ( "context" "fmt" "github.com/jackc/pgx/v5/pgxpool" "github.com/kamu/skincare-backend/internal/inventory" ) func (s *Service) HandlePaymentSuccess(ctx context.Context, pool *pgxpool.Pool, orderID int64) error { tx, err := pool.Begin(ctx) if err != nil { return fmt.Errorf("begin payment tx: %w", err) } defer tx.Rollback(ctx) // Penjaga idempotensi: transisi status hanya berhasil sekali. moved, err := s.orderRepo.MarkPaidIfPending(ctx, tx, orderID) if err != nil { return fmt.Errorf("mark paid: %w", err) } if !moved { return nil // webhook duplikat, sudah paid, jangan sentuh stok } items, err := s.orderRepo.ListItems(ctx, tx, orderID) if err != nil { return fmt.Errorf("list order items: %w", err) } for _, it := range items { err := s.inventoryRepo.ConfirmSold(ctx, tx, inventory.ConfirmSoldParams{ ProductVariantID: it.ProductVariantID, OrderID: orderID, Quantity: it.Quantity, Reason: "payment success", }) if err != nil { return fmt.Errorf("confirm sold variant %d: %w", it.ProductVariantID, err) } } return tx.Commit(ctx) }
Di Laravel kamu mungkin menulis if ($order->isPaid()) return; di awal handler. Itu cek di memori, dan tetap bisa balapan kalau dua webhook tiba bersamaan. Versi Go di sini memindahkan guard ke database lewat UPDATE ... WHERE status = 'pending_payment'. Database yang menjamin transisi terjadi tepat sekali, bukan kode aplikasi.
Release dan Reservation Expiry
Order yang batal atau telat bayar harus mengembalikan stok ke available, otomatis.
Reserved stock adalah janji sementara. Janji tanpa batas waktu adalah kebocoran: stok ditahan order yang tidak akan pernah dibayar, dan customer lain melihat barang habis padahal sebenarnya ada.
Release terjadi saat customer membatalkan order, payment expired, fraud check gagal, atau admin membatalkan. Mekanismenya kebalikan dari reserve: reserved turun, available naik. Validasinya tetap pakai lock agar tidak balapan dengan konfirmasi pembayaran yang mungkin datang di saat yang sama.
internal/inventory/release_reservation.gopackage inventory import ( "context" "fmt" "github.com/jackc/pgx/v5" ) type ReleaseReservationParams struct { ProductVariantID int64 OrderID int64 Quantity int32 Reason string } func (r *Repository) ReleaseReservation(ctx context.Context, tx pgx.Tx, p ReleaseReservationParams) error { if p.Quantity <= 0 { return ErrInvalidQuantity } inv, err := r.lockInventory(ctx, tx, p.ProductVariantID) if err != nil { return err } if inv.ReservedStock < p.Quantity { return fmt.Errorf("%w: reserved %d, need %d", ErrInsufficientStock, inv.ReservedStock, p.Quantity) } nextAvailable := inv.AvailableStock + p.Quantity nextReserved := inv.ReservedStock - p.Quantity _, err = tx.Exec(ctx, ` UPDATE inventories SET available_stock = $2, reserved_stock = $3, version = version + 1, updated_at = NOW() WHERE product_variant_id = $1 `, p.ProductVariantID, nextAvailable, nextReserved) if err != nil { return fmt.Errorf("update release: %w", err) } return r.insertMovement(ctx, tx, movementRow{ variantID: p.ProductVariantID, orderID: &p.OrderID, kind: MovementReleaseReservation, quantity: p.Quantity, from: inv, toAvail: nextAvailable, toReserved: nextReserved, toSold: inv.SoldStock, reason: p.Reason, }) }
flowchart TD
A["Order pending_payment<br/>reservation_expires_at = now + 15m"] --> B{Bayar sebelum expiry?}
B -->|Ya| C[ConfirmSold]
C --> D[Order paid]
D --> G[Shipment diproses]
B -->|Tidak| E["Worker expiry:<br/>SELECT FOR UPDATE SKIP LOCKED"]
E --> F[ReleaseReservation]
F --> H[Order cancelled / expired]Gambar 4. Reserved stock punya dua jalan keluar normal: menjadi sold lewat pembayaran, atau kembali ke available lewat worker expiry.
Siapa yang memicu release saat customer hanya menutup tab tanpa membayar? Tidak ada request masuk, jadi harus ada worker terjadwal. Kita simpan reservation_expires_at di order saat checkout (mis. NOW() + INTERVAL '15 minutes'), lalu worker periodik memungut order yang lewat tenggat dan melepasnya. Ini momen FOR UPDATE SKIP LOCKED bersinar: beberapa worker (atau beberapa container) bisa berjalan bersamaan, masing-masing memungut order berbeda tanpa saling menunggu.
Worker expiry: ambil order kadaluarsa tanpa saling tabrakSELECT id FROM orders WHERE status = 'pending_payment' AND reservation_expires_at < NOW() ORDER BY reservation_expires_at FOR UPDATE SKIP LOCKED LIMIT 50; -- Untuk tiap order: ReleaseReservation per item, lalu set status 'expired', -- semuanya dalam transaksi yang sama.
Pola background worker dan retry sudah dibangun di modul Background Worker Architecture (Roadmap 4). Di sini kita cukup menambah satu job baru: “release reservasi kadaluarsa”. Saat naik ke AWS, scheduler bisa pakai EventBridge yang memicu job, dan antriannya lewat SQS.
Safety Stock dan Adjustment
Buffer operasional agar stok terakhir tidak selalu dijual, plus jalur memperbaiki selisih gudang.
Safety stock adalah stok minimum yang sengaja ditahan untuk risiko operasional: selisih hitung gudang, produk rusak, atau alokasi untuk kanal jualan lain.
Jika available_stock = 10 dan safety_stock = 2, API hanya boleh menjual 8 unit. Aturan ini hidup di backend sebagai sumber kebenaran. UI boleh menampilkan “stok terbatas”, tetapi keputusan boleh-tidaknya menjual tetap di service.
internal/inventory/sellable.gopackage inventory // SellableStock = stok yang benar-benar boleh dijanjikan ke order baru. func (i Inventory) SellableStock() int32 { sellable := i.AvailableStock - i.SafetyStock if sellable < 0 { return 0 } return sellable } func (i Inventory) CanReserve(qty int32) bool { return qty > 0 && i.SellableStock() >= qty }
SellableStock() mirip computed value (atau selector) dari state di React: ia tidak disimpan, tetapi diturunkan dari available dan safety. Bedanya, ia dihitung di backend agar web, mobile, dan admin memakai aturan yang sama persis, bukan tiap client menebak sendiri.
Selisih antara model dan stok fisik gudang diperbaiki lewat manual adjustment: admin mengoreksi available_stock dengan alasan wajib, dan koreksi itu juga tercatat sebagai movement. Restock (barang baru masuk) adalah bentuk khusus adjustment yang menaikkan available.
internal/inventory/adjust.go (inti)// Adjust menaikkan/menurunkan available_stock dengan alasan wajib. // delta boleh negatif (mis. barang rusak), tetapi hasil tetap dijaga >= 0 // oleh CHECK di database. Movement dicatat sebagai manual_adjustment. func (r *Repository) Adjust(ctx context.Context, tx pgx.Tx, variantID int64, delta int32, reason string) error { if reason == "" { return fmt.Errorf("%w: reason wajib untuk adjustment", ErrInvalidQuantity) } inv, err := r.lockInventory(ctx, tx, variantID) if err != nil { return err } next := inv.AvailableStock + delta if next < 0 { return fmt.Errorf("%w: hasil adjustment %d < 0", ErrInsufficientStock, next) } // UPDATE available_stock = next, lalu insertMovement(MovementManualAdjustment ...) // dengan quantity = abs(delta) dan reason yang diberikan admin. return nil }
- Semua stok yang terlihat available langsung bisa dijual.
- Risiko oversell naik saat stok fisik tidak sinkron dengan model.
- Backend menahan buffer minimum per varian.
- Admin punya ruang untuk selisih gudang dan alokasi kanal lain.
API dan Struktur Domain
Mutasi stok kritis dipanggil service domain lain dalam transaksi, bukan oleh customer langsung.
Customer tidak pernah memanggil inventory secara langsung. Customer checkout lewat order, dan order service yang memanggil inventory repository di dalam transaksinya.
/v1/admin/inventories Admin melihat stok per varian beserta ringkasan movement /v1/admin/inventories/:variant_id/movements Admin melihat audit trail perubahan stok satu varian /v1/admin/inventories/:variant_id/adjustments Admin melakukan adjustment / restock dengan alasan wajib /v1/orders Customer checkout, order service melakukan reserve stok dalam transaksi /v1/payments/webhook Payment service mengonfirmasi reserved menjadi sold, idempotent /v1/orders/:order_id/cancel Order service melepas reserved stock jika status masih pending_payment - internal/
- inventory/
- model.go Inventory, Movement, MovementType
- repository.go ReserveStock + lockInventory
- movement.go insertMovement (helper bersama)
- confirm_sold.go reserved -> sold (payment success)
- release_reservation.go reserved -> available (cancel/expiry)
- reserve_atomic.go varian atomic UPDATE (opsi sederhana)
- adjust.go manual adjustment + restock
- sellable.go SellableStock, CanReserve
- service.go orchestration admin adjustment
- handler.go endpoint admin inventory
- order/
- checkout.go reserve stok dalam transaksi checkout
- payment/
- webhook.go ConfirmSold idempotent
- worker/
- expiry_release.go job release reservasi kadaluarsa
- db/
- migrations/
- 021_create_inventories.up.sql
- 021_create_inventories.down.sql
inventory tidak perlu tahu detail cart, voucher, atau alamat kirim. Ia cukup tahu empat hal: varian mana, order mana, berapa banyak, dan kenapa. Batas yang sempit membuat domain ini mudah diuji dan dipakai ulang oleh order maupun payment.
Hands-on: Dua Checkout Berebut Stok Terakhir
Buktikan sendiri bahwa row lock mencegah oversell, dengan dua goroutine sungguhan.
Latihan ini belum memakai HTTP. Tujuannya membuat kamu melihat langsung efek transaksi dan row lock, karena ini perilaku database yang tidak akan muncul di fake repository.
Masukkan satu row inventory: available_stock = 1, reserved_stock = 0, sold_stock = 0, safety_stock = 0.
Keduanya memanggil ReserveStock untuk varian yang sama dengan quantity 1, masing-masing di transaksinya sendiri.
Satu berhasil reserve, satu lagi menerima ErrInsufficientStock setelah lock dilepas dan stok terbaru (0) terbaca.
Harus ada tepat satu movement reserve untuk varian itu. Kalau ada dua, berarti lock-mu tidak bekerja.
internal/inventory/reserve_stock_test.gopackage inventory_test import ( "context" "errors" "sync" "testing" "github.com/jackc/pgx/v5/pgxpool" "github.com/kamu/skincare-backend/internal/inventory" ) func TestReserveStockConcurrentCheckout(t *testing.T) { ctx := context.Background() pool := newTestPool(t) repo := &inventory.Repository{} const variantID int64 = 101 seedInventory(t, pool, variantID, 1, 0, 0, 0) // available=1 var wg sync.WaitGroup errs := make(chan error, 2) for orderID := int64(9001); orderID <= 9002; orderID++ { wg.Add(1) go func(orderID int64) { defer wg.Done() errs <- reserveOne(ctx, pool, repo, variantID, orderID) }(orderID) } wg.Wait() close(errs) var success, stockErr int for err := range errs { switch { case err == nil: success++ case errors.Is(err, inventory.ErrInsufficientStock): stockErr++ default: t.Fatalf("unexpected error: %v", err) } } if success != 1 { t.Fatalf("success = %d, want 1", success) } if stockErr != 1 { t.Fatalf("stockErr = %d, want 1", stockErr) } } func reserveOne(ctx context.Context, pool *pgxpool.Pool, repo *inventory.Repository, variantID, orderID int64) error { tx, err := pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) err = repo.ReserveStock(ctx, tx, inventory.ReserveStockParams{ ProductVariantID: variantID, OrderID: orderID, Quantity: 1, Reason: "concurrent checkout test", }) if err != nil { return err } return tx.Commit(ctx) }
Tes ini wajib jalan di test database PostgreSQL nyata (mis. lewat Docker atau Testcontainers), karena row locking adalah perilaku database. Fake repository in-memory tidak akan pernah memunculkan race yang ingin kita buktikan, jadi lulusnya tes itu palsu.
Jebakan Umum
Kesalahan inventory jarang terlihat di lokal, tetapi muncul tepat saat traffic naik.
Kalau modul checkout adalah tempat uang masuk, modul inventory adalah tempat janji bisnis diuji di bawah tekanan.
Mengurangi stok baru setelah payment
Kalau stok hanya turun saat sudah dibayar, banyak order bisa dibuat dari stok yang sama selama belum ada yang membayar. Di flash sale ini langsung jadi oversell. Turunkan available sejak checkout (reserve).
Tidak satu transaksi
Order, order items, update inventory, dan movement log harus atomic. Satu commit setengah jalan meninggalkan order tanpa stok atau stok tanpa jejak.
Tidak punya movement log
Tanpa log, admin hanya melihat stok salah tanpa tahu kapan dan kenapa. Log adalah alat investigasi, bukan kosmetik.
Mengunci di memori Go
sync.Mutex hanya melindungi satu proses. Di production API berjalan di banyak container, jadi lock harus di database.
Reservasi tanpa expiry
Stok yang ditahan order yang tak pernah dibayar membuat barang “habis” padahal ada. Wajib ada reservation_expires_at dan worker pelepas.
Webhook tidak idempotent
Payment gateway mengirim webhook lebih dari sekali. Tanpa guard transisi status, confirm_sold jalan dua kali dan stok minus.
Mengunci varian dengan urutan acak
Dua order yang mengunci dua varian dalam urutan berbeda bisa deadlock. Urutkan varian menaik sebelum mengunci.
Menampilkan stok tanpa safety stock
UI boleh informatif, tetapi keputusan boleh-jual tetap di SellableStock() di backend.
Manual adjustment tanpa alasan akan jadi sumber konflik operasional yang tak terpecahkan. Buat kolom reason wajib di tingkat aplikasi dan tolak adjustment dengan alasan kosong sebelum menyentuh database.
Ringkasan & Poin Penting
Inventory yang benar membuat checkout aman, payment konsisten, dan admin selalu punya jejak audit saat stok berubah.
Yang Wajib Menempel
- Pisahkan stok jadi
available,reserved, dansold. Checkout memindahkan available ke reserved, bukan langsung ke sold. reserved_stockturun dansold_stocknaik hanya saat payment success tervalidasi, di transaksi yang sama dengan transisi order kepaid.- Reserved stock wajib punya
reservation_expires_atdan worker yang melepasnya saat kadaluarsa, denganFOR UPDATE SKIP LOCKED. - Semua mutasi stok ditulis ke
inventory_movements(denganfrom_*/to_*danreason) di transaksi yang sama. SELECT ... FOR UPDATEmembuat checkout concurrent pada varian yang sama bergiliran; atomicUPDATE ... WHEREadalah alternatif ringkas saat snapshot tak diperlukan.- Kunci varian dengan urutan menaik untuk mencegah deadlock; jaga idempotensi webhook lewat guard transisi status di database.
safety_stockmenahan buffer minimum, danSellableStock()adalah sumber kebenaran boleh-tidaknya menjual.
Di proyek online shop skincare, modul ini adalah fondasi untuk flash sale, payment webhook, shipment, refund, dan admin stock adjustment. Langkah berikutnya, domain payment akan menghubungkan status pembayaran dengan transisi order dan inventory secara idempotent, lalu domain order lifecycle merangkai semua transisi status itu menjadi satu mesin keadaan yang utuh.
Progress disimpan lokal di browser ini.