Golang Order Service: PlaceOrder Atomic, CancelOrder, Outbox Pattern
Implementasi Order Service lengkap Santekno Shop. PlaceOrder dengan atomic stock reserve, CancelOrder 30-menit window, GetOrders pagination — semua dari spec dengan outbox Kafka pattern.
Golang order service adalah artikel paling complex di Part 2 — Order Service mencakup 13 Acceptance Criteria dari 3 spec: PlaceOrder (5 ACs), CancelOrder (4 ACs), GetOrders (4 ACs). Dua challenge utama: atomic stock reserve saat place order, dan atomic stock restore + status update saat cancel, keduanya dijamin lewat single DB transaction dan outbox pattern.
08.01 PlaceOrder UseCase: Full Implementation
UseCase inti dimulai dari validasi input hingga membangun entity Order. Kode berikut memvalidasi setiap item, mengambil snapshot harga, lalu menyusun order sebelum di-persist.
1// internal/usecase/order/place_order.go
2
3package order
4
5import (
6 "context"
7 "fmt"
8 "time"
9
10 "github.com/google/uuid"
11 domainOrder "github.com/santekno/santekno-shop/internal/domain/order"
12 domainProduct "github.com/santekno/santekno-shop/internal/domain/product"
13 "github.com/santekno/santekno-shop/internal/event"
14)
15
16func (uc *PlaceOrderUseCase) Execute(ctx context.Context, input PlaceOrderInput) (*domainOrder.Order, error) {
17 // AC5: Empty order
18 if len(input.Items) == 0 {
19 return nil, domainOrder.ErrEmptyOrder
20 }
21 // Business rule: max 10 items
22 if len(input.Items) > 10 {
23 return nil, domainOrder.ErrMaxItemsExceeded
24 }
25
26 // Collect all product IDs
27 productIDs := make([]string, len(input.Items))
28 for i, item := range input.Items {
29 productIDs[i] = item.ProductID.String()
30 }
31
32 // Validate all products exist and build order items
33 orderItems := make([]domainOrder.OrderItem, 0, len(input.Items))
34 var totalIDR int64
35
36 for _, inputItem := range input.Items {
37 // AC3: Product tidak ditemukan
38 p, err := uc.productRepo.GetByID(ctx, inputItem.ProductID.String())
39 if err != nil {
40 return nil, fmt.Errorf("placeOrder.Execute: %w", err)
41 }
42 if p == nil {
43 return nil, fmt.Errorf("%w: product_id=%s", domainProduct.ErrProductNotFound, inputItem.ProductID)
44 }
45 if !p.IsActive {
46 return nil, fmt.Errorf("%w: product_id=%s", domainProduct.ErrProductNotFound, inputItem.ProductID)
47 }
48
49 // AC2: Check available stock (before reservation)
50 if p.AvailableStock() < inputItem.Quantity {
51 return nil, fmt.Errorf("%w: product_id=%s", domainProduct.ErrInsufficientStock, inputItem.ProductID)
52 }
53
54 subtotal := p.PriceIDR * int64(inputItem.Quantity)
55 totalIDR += subtotal
56
57 orderItems = append(orderItems, domainOrder.OrderItem{
58 ID: uuid.New(),
59 ProductID: inputItem.ProductID,
60 ProductName: p.Name, // snapshot
61 UnitPriceIDR: p.PriceIDR, // snapshot
62 Quantity: inputItem.Quantity,
63 SubtotalIDR: subtotal,
64 })
65 }
66
67 // Build Order entity
68 now := time.Now().UTC()
69 o := &domainOrder.Order{
70 ID: uuid.New(),
71 UserID: input.UserID,
72 Status: domainOrder.StatusPending,
73 Items: orderItems,
74 TotalAmountIDR: totalIDR,
75 PaymentMethod: input.PaymentMethod,
76 CreatedAt: now,
77 UpdatedAt: now,
78 }
79
80 // AC1: Atomic create order + reserve stock (single DB transaction)
81 if err := uc.orderRepo.Create(ctx, o); err != nil {
82 return nil, fmt.Errorf("placeOrder.Execute: %w", err)
83 }
84
85 // Publish event (via outbox — sudah dalam transaksi di Create)
86 _ = uc.publisher.Publish(ctx, event.Event{
87 Topic: event.TopicOrderPlaced,
88 Key: o.ID.String(),
89 Payload: event.OrderPlacedPayload{
90 OrderID: o.ID.String(),
91 UserID: o.UserID.String(),
92 TotalAmountIDR: o.TotalAmountIDR,
93 ItemCount: len(o.Items),
94 },
95 })
96
97 return o, nil
98}Snapshot ProductName dan UnitPriceIDR diambil saat order dibuat — jadi jika admin mengubah harga besok, riwayat order lama tetap menampilkan harga yang benar-benar dibayar customer.
08.02 Order Repository: Create dengan Atomic Stock Reserve
Persistensi order harus atomik: insert order, items, reserve stok, dan outbox event dalam satu transaksi. Kode berikut membungkus keempatnya dalam satu tx.
1// internal/repository/postgres/order_repository.go
2
3package postgres
4
5import (
6 "context"
7 "encoding/json"
8 "fmt"
9
10 "github.com/google/uuid"
11 "github.com/jackc/pgx/v5"
12 "github.com/jackc/pgx/v5/pgxpool"
13
14 domainOrder "github.com/santekno/santekno-shop/internal/domain/order"
15 "github.com/santekno/santekno-shop/internal/event"
16)
17
18type OrderRepository struct {
19 db *pgxpool.Pool
20}
21
22func NewOrderRepository(db *pgxpool.Pool) *OrderRepository {
23 return &OrderRepository{db: db}
24}
25
26// Create: satu transaksi untuk insert order + items + reserve stock + outbox event
27func (r *OrderRepository) Create(ctx context.Context, o *domainOrder.Order) error {
28 tx, err := r.db.Begin(ctx)
29 if err != nil {
30 return fmt.Errorf("orderRepo.Create: begin tx: %w", err)
31 }
32 defer tx.Rollback(ctx) // no-op jika commit sukses
33
34 // 1. Insert order
35 _, err = tx.Exec(ctx,
36 `INSERT INTO orders (id, user_id, status, total_amount_idr, payment_method,
37 shipping_address, created_at, updated_at)
38 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
39 o.ID, o.UserID, o.Status, o.TotalAmountIDR, o.PaymentMethod,
40 mustJSON(o.ShippingAddress), o.CreatedAt, o.UpdatedAt,
41 )
42 if err != nil {
43 return fmt.Errorf("orderRepo.Create: insert order: %w", err)
44 }
45
46 // 2. Insert order items
47 for _, item := range o.Items {
48 _, err = tx.Exec(ctx,
49 `INSERT INTO order_items (id, order_id, product_id, product_name,
50 unit_price_idr, quantity, subtotal_idr)
51 VALUES ($1, $2, $3, $4, $5, $6, $7)`,
52 item.ID, o.ID, item.ProductID, item.ProductName,
53 item.UnitPriceIDR, item.Quantity, item.SubtotalIDR,
54 )
55 if err != nil {
56 return fmt.Errorf("orderRepo.Create: insert item: %w", err)
57 }
58 }
59
60 // 3. Reserve stock untuk setiap product (atomic per item)
61 for _, item := range o.Items {
62 result, err := tx.Exec(ctx,
63 `UPDATE products
64 SET reserved_stock = reserved_stock + $1, updated_at = NOW()
65 WHERE id = $2
66 AND (stock - reserved_stock) >= $1
67 AND is_active = true`,
68 item.Quantity, item.ProductID,
69 )
70 if err != nil {
71 return fmt.Errorf("orderRepo.Create: reserve stock: %w", err)
72 }
73 if result.RowsAffected() == 0 {
74 // Stock tidak cukup — rollback otomatis oleh defer
75 return fmt.Errorf("orderRepo.Create: %w: product_id=%s",
76 domainProduct.ErrInsufficientStock, item.ProductID)
77 }
78 }
79
80 // 4. Insert outbox event (reliable event publishing)
81 payload, _ := json.Marshal(event.OrderPlacedPayload{
82 OrderID: o.ID.String(),
83 UserID: o.UserID.String(),
84 TotalAmountIDR: o.TotalAmountIDR,
85 ItemCount: len(o.Items),
86 })
87 _, err = tx.Exec(ctx,
88 `INSERT INTO outbox_events (event_type, topic, payload)
89 VALUES ($1, $2, $3)`,
90 "order.placed", event.TopicOrderPlaced, payload,
91 )
92 if err != nil {
93 return fmt.Errorf("orderRepo.Create: insert outbox: %w", err)
94 }
95
96 // Commit semua: order + items + stock reserve + outbox
97 if err := tx.Commit(ctx); err != nil {
98 return fmt.Errorf("orderRepo.Create: commit: %w", err)
99 }
100
101 return nil
102}
103
104func mustJSON(v interface{}) []byte {
105 b, _ := json.Marshal(v)
106 return b
107}Jika salah satu item stoknya tidak cukup (0 rows affected), defer tx.Rollback membatalkan seluruh transaksi — tidak ada order parsial, tidak ada stok yang bocor ter-reserve.
08.03 CancelOrder UseCase
Pembatalan order menggabungkan aturan bisnis (window 30 menit) dengan operasi atomik. Kode berikut mendelegasikan keputusan “boleh cancel atau tidak” ke entity.
1// internal/usecase/order/cancel_order.go
2
3package order
4
5import (
6 "context"
7 "fmt"
8
9 "github.com/google/uuid"
10 domainOrder "github.com/santekno/santekno-shop/internal/domain/order"
11 "github.com/santekno/santekno-shop/internal/event"
12)
13
14func (uc *CancelOrderUseCase) Execute(ctx context.Context, orderID, userID uuid.UUID) error {
15 // Fetch order
16 o, err := uc.orderRepo.GetByIDAndUserID(ctx, orderID, userID)
17 if err != nil {
18 return fmt.Errorf("cancelOrder.Execute: %w", err)
19 }
20
21 // AC4: Not found / not owned by user
22 if o == nil {
23 return domainOrder.ErrOrderNotFound
24 }
25
26 // AC1+AC2+AC3: domain entity menentukan bisa cancel atau tidak
27 if err := o.CanBeCancelled(); err != nil {
28 return err // ErrOrderNotCancellable atau ErrCancelWindowExpired
29 }
30
31 // AC1: Atomic cancel + stock restore dalam satu transaksi
32 if err := uc.orderRepo.Cancel(ctx, orderID); err != nil {
33 return fmt.Errorf("cancelOrder.Execute: %w", err)
34 }
35
36 // Publish event async (tidak block response)
37 go func() {
38 _ = uc.publisher.Publish(context.Background(), event.Event{
39 Topic: event.TopicOrderCancelled,
40 Key: orderID.String(),
41 Payload: event.OrderCancelledPayload{
42 OrderID: orderID.String(),
43 UserID: userID.String(),
44 Reason: "user_cancelled",
45 },
46 })
47 }()
48
49 return nil
50}Query GetByIDAndUserID sekaligus menegakkan AC4: jika order bukan milik user tersebut, hasilnya nil — mencegah user A membatalkan order user B tanpa pengecekan otorisasi terpisah.
08.04 Order Repository: Cancel dengan Atomic Stock Restore
Cancel di layer repository harus mengembalikan stok yang ter-reserve sekaligus mengubah status. Kode berikut melakukan keduanya plus outbox event dalam satu transaksi.
1// Cancel: satu transaksi untuk update status + restore stock + outbox
2func (r *OrderRepository) Cancel(ctx context.Context, orderID uuid.UUID) error {
3 tx, err := r.db.Begin(ctx)
4 if err != nil {
5 return fmt.Errorf("orderRepo.Cancel: begin: %w", err)
6 }
7 defer tx.Rollback(ctx)
8
9 // 1. Update order status to CANCELLED
10 result, err := tx.Exec(ctx,
11 `UPDATE orders SET status = 'CANCELLED', updated_at = NOW()
12 WHERE id = $1 AND status = 'PENDING'`,
13 orderID,
14 )
15 if err != nil {
16 return fmt.Errorf("orderRepo.Cancel: update status: %w", err)
17 }
18 if result.RowsAffected() == 0 {
19 return domainOrder.ErrOrderNotCancellable
20 }
21
22 // 2. Fetch order items untuk restore stock
23 rows, err := tx.Query(ctx,
24 `SELECT product_id, quantity FROM order_items WHERE order_id = $1`,
25 orderID,
26 )
27 if err != nil {
28 return fmt.Errorf("orderRepo.Cancel: fetch items: %w", err)
29 }
30 defer rows.Close()
31
32 type stockRestore struct {
33 productID string
34 quantity int
35 }
36 var restores []stockRestore
37 for rows.Next() {
38 var sr stockRestore
39 if err := rows.Scan(&sr.productID, &sr.quantity); err != nil {
40 return fmt.Errorf("orderRepo.Cancel: scan item: %w", err)
41 }
42 restores = append(restores, sr)
43 }
44
45 // 3. Restore reserved stock
46 for _, sr := range restores {
47 _, err = tx.Exec(ctx,
48 `UPDATE products
49 SET reserved_stock = GREATEST(0, reserved_stock - $1), updated_at = NOW()
50 WHERE id = $2`,
51 sr.quantity, sr.productID,
52 )
53 if err != nil {
54 return fmt.Errorf("orderRepo.Cancel: restore stock: %w", err)
55 }
56 }
57
58 // 4. Insert outbox event
59 payload, _ := json.Marshal(map[string]string{
60 "order_id": orderID.String(),
61 "reason": "user_cancelled",
62 })
63 _, err = tx.Exec(ctx,
64 `INSERT INTO outbox_events (event_type, topic, payload)
65 VALUES ($1, $2, $3)`,
66 "order.cancelled", event.TopicOrderCancelled, payload,
67 )
68 if err != nil {
69 return fmt.Errorf("orderRepo.Cancel: insert outbox: %w", err)
70 }
71
72 return tx.Commit(ctx)
73}Kondisi WHERE ... AND status = 'PENDING' bertindak sebagai guard anti double-cancel: jika order sudah dibatalkan sebelumnya, 0 rows affected dan operasi berhenti aman tanpa mengembalikan stok dua kali.
08.05 GetOrders UseCase
Menampilkan riwayat order butuh pagination dan default yang aman. Kode berikut menyanitasi input dan menghitung total halaman.
1// internal/usecase/order/get_orders.go
2
3package order
4
5import (
6 "context"
7 "fmt"
8 "math"
9
10 "github.com/google/uuid"
11 domainOrder "github.com/santekno/santekno-shop/internal/domain/order"
12)
13
14func (uc *GetOrdersUseCase) Execute(ctx context.Context, input GetOrdersInput) (*GetOrdersOutput, error) {
15 // Defaults
16 if input.Page < 1 { input.Page = 1 }
17 if input.Limit < 1 { input.Limit = 10 }
18 if input.Limit > 100 { input.Limit = 100 }
19
20 orders, total, err := uc.orderRepo.ListByUserID(ctx,
21 input.UserID, input.Page, input.Limit, input.Status)
22 if err != nil {
23 return nil, fmt.Errorf("getOrders.Execute: %w", err)
24 }
25
26 totalPages := int(math.Ceil(float64(total) / float64(input.Limit)))
27 if totalPages < 1 { totalPages = 1 }
28
29 return &GetOrdersOutput{
30 Orders: orders,
31 Total: total,
32 Page: input.Page,
33 Limit: input.Limit,
34 TotalPages: totalPages,
35 }, nil
36}input.UserID selalu diteruskan ke repository, memastikan setiap query order otomatis ter-scope ke user yang sedang login — dasar dari AC4 get-orders.
08.06 GetByIDAndUserID dan ListByUserID
Dua query repository ini menegakkan isolasi antar-user. Kode berikut selalu memfilter berdasarkan user_id, baik untuk satu order maupun list.
1func (r *OrderRepository) GetByIDAndUserID(ctx context.Context, id, userID uuid.UUID) (*domainOrder.Order, error) {
2 var o domainOrder.Order
3 err := r.db.QueryRow(ctx,
4 `SELECT id, user_id, status, total_amount_idr, payment_method,
5 payment_url, shipping_address, created_at, updated_at
6 FROM orders WHERE id = $1 AND user_id = $2`,
7 id, userID,
8 ).Scan(
9 &o.ID, &o.UserID, &o.Status, &o.TotalAmountIDR,
10 &o.PaymentMethod, &o.PaymentURL, &o.ShippingAddress,
11 &o.CreatedAt, &o.UpdatedAt,
12 )
13 if err != nil {
14 if err == pgx.ErrNoRows { return nil, nil }
15 return nil, fmt.Errorf("orderRepo.GetByIDAndUserID: %w", err)
16 }
17
18 // Fetch items
19 o.Items, err = r.getOrderItems(ctx, o.ID)
20 if err != nil { return nil, err }
21
22 return &o, nil
23}
24
25func (r *OrderRepository) ListByUserID(ctx context.Context, userID uuid.UUID, page, limit int, status *domainOrder.Status) ([]*domainOrder.Order, int, error) {
26 args := []interface{}{userID}
27 whereExtra := ""
28
29 // AC2: filter by status
30 if status != nil {
31 whereExtra = " AND status = $2"
32 args = append(args, *status)
33 }
34
35 var total int
36 r.db.QueryRow(ctx,
37 fmt.Sprintf("SELECT COUNT(*) FROM orders WHERE user_id = $1%s", whereExtra),
38 args...,
39 ).Scan(&total)
40
41 offset := (page - 1) * limit
42 args = append(args, limit, offset)
43 limitIdx := len(args) - 1
44 offsetIdx := len(args)
45
46 query := fmt.Sprintf(
47 `SELECT id, user_id, status, total_amount_idr, payment_method,
48 payment_url, created_at, updated_at
49 FROM orders
50 WHERE user_id = $1%s
51 ORDER BY created_at DESC
52 LIMIT $%d OFFSET $%d`,
53 whereExtra, limitIdx, offsetIdx,
54 )
55
56 rows, err := r.db.Query(ctx, query, args...)
57 if err != nil {
58 return nil, 0, fmt.Errorf("orderRepo.ListByUserID: %w", err)
59 }
60 defer rows.Close()
61
62 var orders []*domainOrder.Order
63 for rows.Next() {
64 var o domainOrder.Order
65 if err := rows.Scan(
66 &o.ID, &o.UserID, &o.Status, &o.TotalAmountIDR,
67 &o.PaymentMethod, &o.PaymentURL, &o.CreatedAt, &o.UpdatedAt,
68 ); err != nil {
69 return nil, 0, fmt.Errorf("orderRepo.ListByUserID: scan: %w", err)
70 }
71 orders = append(orders, &o)
72 }
73
74 return orders, total, nil
75}Filter user_id = $1 ada di setiap query — isolasi data dilakukan di level database, bukan di layer aplikasi yang lebih rawan lupa atau di-bypass.
08.07 Order Handler
Handler menerjemahkan HTTP request menjadi input usecase, lengkap dengan validasi struct dan otorisasi via middleware. Kode berikut mencakup ketiga endpoint order.
1// delivery/http/handler/order_handler.go
2
3package handler
4
5import (
6 "net/http"
7
8 "github.com/google/uuid"
9 "github.com/labstack/echo/v4"
10 domainOrder "github.com/santekno/santekno-shop/internal/domain/order"
11 orderUC "github.com/santekno/santekno-shop/internal/usecase/order"
12 "github.com/santekno/santekno-shop/delivery/http/middleware"
13 "github.com/santekno/santekno-shop/pkg/apierror"
14 "github.com/santekno/santekno-shop/pkg/pagination"
15)
16
17type placeOrderRequest struct {
18 Items []struct {
19 ProductID string `json:"product_id" validate:"required,uuid"`
20 Quantity int `json:"quantity" validate:"required,min=1,max=100"`
21 } `json:"items" validate:"required,min=1,max=10,dive"`
22 ShippingAddressID string `json:"shipping_address_id" validate:"required,uuid"`
23 PaymentMethod string `json:"payment_method" validate:"required,oneof=BANK_TRANSFER QRIS CREDIT_CARD"`
24}
25
26func (h *OrderHandler) PlaceOrder(c echo.Context) error {
27 userID, ok := middleware.GetUserID(c)
28 if !ok {
29 return c.JSON(http.StatusUnauthorized, apierror.Response{Error: "UNAUTHORIZED"})
30 }
31
32 var req placeOrderRequest
33 if err := c.Bind(&req); err != nil {
34 return c.JSON(http.StatusBadRequest, apierror.Response{Error: "INVALID_REQUEST"})
35 }
36 if err := c.Validate(&req); err != nil {
37 return c.JSON(http.StatusBadRequest, apierror.Response{Error: "VALIDATION_FAILED"})
38 }
39
40 // Build usecase input
41 items := make([]orderUC.PlaceOrderItem, len(req.Items))
42 for i, item := range req.Items {
43 productID, _ := uuid.Parse(item.ProductID)
44 items[i] = orderUC.PlaceOrderItem{
45 ProductID: productID,
46 Quantity: item.Quantity,
47 }
48 }
49
50 shippingAddrID, _ := uuid.Parse(req.ShippingAddressID)
51
52 order, err := h.placeOrderUC.Execute(c.Request().Context(), orderUC.PlaceOrderInput{
53 UserID: userID,
54 Items: items,
55 ShippingAddressID: shippingAddrID,
56 PaymentMethod: domainOrder.PaymentMethod(req.PaymentMethod),
57 })
58 if err != nil {
59 return apierror.Handle(c, err)
60 }
61
62 return c.JSON(http.StatusCreated, toOrderResponse(order))
63}
64
65func (h *OrderHandler) CancelOrder(c echo.Context) error {
66 userID, ok := middleware.GetUserID(c)
67 if !ok {
68 return c.JSON(http.StatusUnauthorized, apierror.Response{Error: "UNAUTHORIZED"})
69 }
70
71 orderID, err := uuid.Parse(c.Param("id"))
72 if err != nil {
73 return c.JSON(http.StatusBadRequest, apierror.Response{Error: "INVALID_ORDER_ID"})
74 }
75
76 if err := h.cancelOrderUC.Execute(c.Request().Context(), orderID, userID); err != nil {
77 return apierror.Handle(c, err)
78 }
79
80 return c.NoContent(http.StatusNoContent)
81}
82
83func (h *OrderHandler) GetOrders(c echo.Context) error {
84 userID, ok := middleware.GetUserID(c)
85 if !ok {
86 return c.JSON(http.StatusUnauthorized, apierror.Response{Error: "UNAUTHORIZED"})
87 }
88
89 p := pagination.ParseParams(c)
90 var statusFilter *domainOrder.Status
91 if s := c.QueryParam("status"); s != "" {
92 status := domainOrder.Status(s)
93 statusFilter = &status
94 }
95
96 out, err := h.getOrdersUC.Execute(c.Request().Context(), orderUC.GetOrdersInput{
97 UserID: userID,
98 Page: p.Page,
99 Limit: p.Limit,
100 Status: statusFilter,
101 })
102 if err != nil {
103 return apierror.Handle(c, err)
104 }
105
106 return c.JSON(http.StatusOK, map[string]interface{}{
107 "data": toOrderResponses(out.Orders),
108 "pagination": pagination.NewMeta(out.Page, out.Limit, out.Total),
109 })
110}Tag validasi max=10,dive pada Items menegakkan business rule sejak lapisan handler — request dengan lebih dari 10 item ditolak dengan 400 sebelum menyentuh usecase atau database.
08.08 PlaceOrder Tests
Kelima AC PlaceOrder harus tercakup test. Suite berikut menguji jalur sukses hingga setiap kondisi error.
1// internal/usecase/order/place_order_test.go
2
3// AC1: Order berhasil dibuat
4func (s *PlaceOrderSuite) TestPlaceOrder_ValidItems_ReturnsCreatedOrder() {
5 product := &domainProduct.Product{
6 ID: uuid.New(), Name: "Test Product",
7 PriceIDR: 100_000_00, Stock: 10, ReservedStock: 0, IsActive: true,
8 }
9 s.productRepo.EXPECT().GetByID(gomock.Any(), product.ID.String()).Return(product, nil)
10 s.orderRepo.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil)
11 s.publisher.EXPECT().Publish(gomock.Any(), gomock.Any()).Return(nil)
12
13 order, err := s.uc.Execute(context.Background(), orderUC.PlaceOrderInput{
14 UserID: uuid.New(),
15 Items: []orderUC.PlaceOrderItem{{ProductID: product.ID, Quantity: 2}},
16 PaymentMethod: domainOrder.PaymentBankTransfer,
17 })
18
19 s.NoError(err)
20 s.NotNil(order)
21 s.Equal(domainOrder.StatusPending, order.Status)
22 s.Equal(int64(200_000_00), order.TotalAmountIDR) // 2 × 100_000_00
23}
24
25// AC2: Stok tidak cukup
26func (s *PlaceOrderSuite) TestPlaceOrder_InsufficientStock_ReturnsError() {
27 product := &domainProduct.Product{
28 ID: uuid.New(), PriceIDR: 100_000_00,
29 Stock: 2, ReservedStock: 0, IsActive: true,
30 }
31 s.productRepo.EXPECT().GetByID(gomock.Any(), product.ID.String()).Return(product, nil)
32
33 _, err := s.uc.Execute(context.Background(), orderUC.PlaceOrderInput{
34 Items: []orderUC.PlaceOrderItem{{ProductID: product.ID, Quantity: 5}},
35 })
36
37 s.ErrorIs(err, domainProduct.ErrInsufficientStock)
38}
39
40// AC3: Product tidak ada
41func (s *PlaceOrderSuite) TestPlaceOrder_ProductNotFound_ReturnsError() {
42 s.productRepo.EXPECT().GetByID(gomock.Any(), gomock.Any()).Return(nil, nil) // nil = not found
43
44 _, err := s.uc.Execute(context.Background(), orderUC.PlaceOrderInput{
45 Items: []orderUC.PlaceOrderItem{{ProductID: uuid.New(), Quantity: 1}},
46 })
47
48 s.ErrorIs(err, domainProduct.ErrProductNotFound)
49}
50
51// AC5: Empty order
52func (s *PlaceOrderSuite) TestPlaceOrder_EmptyItems_ReturnsEmptyOrderError() {
53 _, err := s.uc.Execute(context.Background(), orderUC.PlaceOrderInput{Items: []orderUC.PlaceOrderItem{}})
54 s.ErrorIs(err, domainOrder.ErrEmptyOrder)
55}
56
57// Business rule: max 10 items
58func (s *PlaceOrderSuite) TestPlaceOrder_Over10Items_ReturnsMaxItemsExceeded() {
59 items := make([]orderUC.PlaceOrderItem, 11)
60 for i := range items { items[i] = orderUC.PlaceOrderItem{ProductID: uuid.New(), Quantity: 1} }
61
62 _, err := s.uc.Execute(context.Background(), orderUC.PlaceOrderInput{Items: items})
63 s.ErrorIs(err, domainOrder.ErrMaxItemsExceeded)
64}Setiap test memakai s.ErrorIs untuk memverifikasi error domain spesifik — bukan sekadar “ada error” — sehingga mapping ke status HTTP (422, 404, 400) benar-benar terjamin.
08.09 CancelOrder Tests
CancelOrder punya empat AC yang bergantung pada waktu dan status. Test berikut memanipulasi CreatedAt untuk menguji window 30 menit.
1// internal/usecase/order/cancel_order_test.go
2
3// AC1: Cancel PENDING order dalam window
4func (s *CancelOrderSuite) TestCancelOrder_PendingWithin30Min_ReturnsNil() {
5 pendingOrder := &domainOrder.Order{
6 ID: uuid.New(),
7 UserID: s.userID,
8 Status: domainOrder.StatusPending,
9 CreatedAt: time.Now().Add(-10 * time.Minute), // dalam window
10 }
11 s.orderRepo.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), s.userID).Return(pendingOrder, nil)
12 s.orderRepo.EXPECT().Cancel(gomock.Any(), gomock.Any()).Return(nil)
13 // publisher called async, difficult to assert — use Eventually or skip
14
15 err := s.uc.Execute(context.Background(), pendingOrder.ID, s.userID)
16 s.NoError(err)
17}
18
19// AC2: Order bukan PENDING
20func (s *CancelOrderSuite) TestCancelOrder_ConfirmedOrder_ReturnsOrderNotCancellable() {
21 confirmedOrder := &domainOrder.Order{
22 ID: uuid.New(),
23 Status: domainOrder.StatusConfirmed,
24 CreatedAt: time.Now(),
25 }
26 s.orderRepo.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), s.userID).Return(confirmedOrder, nil)
27
28 err := s.uc.Execute(context.Background(), confirmedOrder.ID, s.userID)
29 s.ErrorIs(err, domainOrder.ErrOrderNotCancellable)
30}
31
32// AC3: Window sudah lewat
33func (s *CancelOrderSuite) TestCancelOrder_WindowExpired_ReturnsCancelWindowExpired() {
34 expiredOrder := &domainOrder.Order{
35 ID: uuid.New(),
36 Status: domainOrder.StatusPending,
37 CreatedAt: time.Now().Add(-31 * time.Minute), // sudah lewat 30 menit
38 }
39 s.orderRepo.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), s.userID).Return(expiredOrder, nil)
40
41 err := s.uc.Execute(context.Background(), expiredOrder.ID, s.userID)
42 s.ErrorIs(err, domainOrder.ErrCancelWindowExpired)
43}
44
45// AC4: Order tidak ditemukan
46func (s *CancelOrderSuite) TestCancelOrder_NotFound_ReturnsOrderNotFound() {
47 s.orderRepo.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), s.userID).Return(nil, nil)
48
49 err := s.uc.Execute(context.Background(), uuid.New(), s.userID)
50 s.ErrorIs(err, domainOrder.ErrOrderNotFound)
51}Karena aturan window tinggal di entity, kita bisa menguji ekspirasi cukup dengan mengeset CreatedAt ke masa lalu — tanpa perlu memanipulasi jam sistem atau menyentuh database.
08.10 GetOrders Tests
GetOrders diuji untuk pagination, filter status, dan isolasi user. Suite berikut menegaskan argumen yang diterima repository.
1// internal/usecase/order/get_orders_test.go
2
3// AC1: List order dengan pagination
4func (s *GetOrdersSuite) TestGetOrders_ValidInput_ReturnsPaginatedOrders() {
5 mockOrders := []*domainOrder.Order{
6 {ID: uuid.New(), Status: domainOrder.StatusPending},
7 {ID: uuid.New(), Status: domainOrder.StatusConfirmed},
8 }
9 s.orderRepo.EXPECT().ListByUserID(gomock.Any(), s.userID, 1, 10, nil).Return(mockOrders, 2, nil)
10
11 out, err := s.uc.Execute(context.Background(), orderUC.GetOrdersInput{
12 UserID: s.userID, Page: 1, Limit: 10,
13 })
14
15 s.NoError(err)
16 s.Len(out.Orders, 2)
17 s.Equal(2, out.Total)
18 s.Equal(1, out.TotalPages)
19}
20
21// AC2: Filter by status
22func (s *GetOrdersSuite) TestGetOrders_FilterByStatus_PassesStatusToRepo() {
23 status := domainOrder.StatusPending
24 s.orderRepo.EXPECT().ListByUserID(gomock.Any(), s.userID, 1, 10, &status).Return(nil, 0, nil)
25
26 _, err := s.uc.Execute(context.Background(), orderUC.GetOrdersInput{
27 UserID: s.userID, Page: 1, Limit: 10, Status: &status,
28 })
29 s.NoError(err)
30}
31
32// AC4: User hanya lihat order sendiri
33func (s *GetOrdersSuite) TestGetOrders_ScopedToUser_RepoCalledWithUserID() {
34 specificUserID := uuid.New()
35 s.orderRepo.EXPECT().
36 ListByUserID(gomock.Any(), specificUserID, gomock.Any(), gomock.Any(), nil).
37 Return(nil, 0, nil)
38
39 _, err := s.uc.Execute(context.Background(), orderUC.GetOrdersInput{UserID: specificUserID})
40 s.NoError(err)
41}Test ScopedToUser mem-verifikasi specificUserID benar-benar diteruskan ke repository — bukti bahwa AC4 (isolasi antar-user) dijaga oleh kontrak, bukan kebetulan.
08.11 Price Snapshot Pattern
Kenapa harga harus di-snapshot? Diagram berikut menjelaskan bahaya menghitung ulang harga dari tabel produk saat menampilkan riwayat order.
1KENAPA PRICE SNAPSHOT PENTING?
2
3Scenario tanpa snapshot:
4 Customer beli produk Rp 100.000
5 Admin update harga jadi Rp 150.000
6 Customer melihat receipt: Rp 150.000 ???
7
8DENGAN price snapshot di order_items:
9 order_items.unit_price_idr = harga SAAT ORDER DIBUAT
10 products.price_idr bisa berubah → tidak affect order lama
11
12Implementation:
13 Saat PlaceOrder:
14 item.ProductName = p.Name // snapshot nama
15 item.UnitPriceIDR = p.PriceIDR // snapshot harga
16 item.SubtotalIDR = item.UnitPriceIDR * qty
17
18 Setelah order dibuat, products table bisa update → safeDengan menyimpan harga di order_items, riwayat pembelian menjadi immutable — perubahan harga produk di masa depan tidak pernah mengubah nilai order yang sudah terjadi.
08.12 Idempotency Pattern untuk PlaceOrder
PlaceOrder tidak idempotent secara alami — double-click bisa membuat dua order. Kode berikut menambahkan idempotency key berbasis Redis.
1// PlaceOrder TIDAK naturally idempotent
2// Double-click bisa create 2 orders!
3// Solution: idempotency key dari client
4
5// Client kirim header: Idempotency-Key: <uuid>
6// Server simpan: redis.SET "idempotency:<key>" order_id EX 86400
7
8func (h *OrderHandler) PlaceOrder(c echo.Context) error {
9 idempotencyKey := c.Request().Header.Get("Idempotency-Key")
10 if idempotencyKey != "" {
11 // Check if already processed
12 if cachedOrderID, err := h.cache.Get(ctx, "idempotency:"+idempotencyKey).Result(); err == nil {
13 // Return existing order
14 order, _ := h.getOrderUC.Execute(ctx, cachedOrderID)
15 return c.JSON(http.StatusOK, toOrderResponse(order)) // 200, not 201
16 }
17 }
18
19 order, err := h.placeOrderUC.Execute(...)
20 if err != nil { return apierror.Handle(c, err) }
21
22 // Cache the result
23 if idempotencyKey != "" {
24 h.cache.Set(ctx, "idempotency:"+idempotencyKey, order.ID.String(), 24*time.Hour)
25 }
26
27 return c.JSON(http.StatusCreated, toOrderResponse(order))
28}Perhatikan status code berbeda: request pertama mengembalikan 201 Created, sedangkan retry dengan key sama mengembalikan 200 OK dengan order yang sama — klien bisa retry aman tanpa risiko duplikasi.
08.13 Outbox Pattern: Kenapa Penting
Outbox pattern menjamin event terkirim meski Kafka down. Diagram berikut membandingkan alur tanpa dan dengan outbox.
1TANPA OUTBOX:
2 tx.Commit() → order created in DB ✅
3 publisher.Publish() → Kafka publish fails ❌
4
5 Order ada di DB, tapi event tidak pernah terkirim.
6 Notification tidak terkirim.
7 Payment tidak diinisiasi.
8
9 Cara fix: retry manual, atau event hilang selamanya.
10
11DENGAN OUTBOX:
12 tx.Begin()
13 INSERT order
14 INSERT order_items
15 UPDATE products (reserve stock)
16 INSERT outbox_events ← event di-commit bersama order
17 tx.Commit()
18
19 Background worker:
20 SELECT * FROM outbox_events WHERE published = false
21 → Publish ke Kafka
22 → UPDATE outbox_events SET published = true
23
24 Jika Kafka down:
25 - Order tetap tersimpan ✅
26 - outbox_events berstatus published = false
27 - Worker retry otomatis ketika Kafka kembali UP
28
29 At-least-once delivery:
30 Consumer Kafka HARUS idempotent (Artikel 10, 09).Kunci outbox: event ditulis ke DB dalam transaksi yang sama dengan order, jadi tidak akan pernah ada order tanpa event-nya — masalah “dual write” antara DB dan message broker terpecahkan.
08.14 Specify Audit Score Setelah Artikel 08
Order Service menutup mayoritas AC yang tersisa. Perintah audit berikut menunjukkan score mencapai threshold CI.
1specify audit --output json | jq '{
2 score: .score,
3 covered: [.features[] | select(.status == "covered") | .feature]
4}'
5
6# Expected:
7# {
8# "score": 80,
9# "covered": [
10# "user-registration", ← Artikel 06
11# "user-login", ← Artikel 06
12# "product-catalog", ← Artikel 07
13# "place-order", ← Artikel 08 (5 ACs)
14# "cancel-order", ← Artikel 08 (4 ACs)
15# "get-orders" ← Artikel 08 (4 ACs)
16# ]
17# }
18#
19# 80/100 → CI pipeline pass!
20# Payment-webhook (4 ACs) masih uncovered → Artikel 09Score 80/100 adalah momen penting: untuk pertama kalinya CI quality gate untuk spec compliance lolos — bukti bahwa tiga service inti benar-benar memenuhi spesifikasi.
08.15 Integration Test: PlaceOrder
Integration test membuktikan atomicity terhadap PostgreSQL nyata. Test berikut memverifikasi stok ter-reserve dan outbox event tercipta dalam satu Create.
1//go:build integration
2
3func TestOrderRepository_CreateOrder_AtomicStockReserve(t *testing.T) {
4 pool := setupDB(t)
5 orderRepo := postgres.NewOrderRepository(pool)
6 ctx := context.Background()
7
8 // Create test product with 5 stock
9 var productID string
10 pool.QueryRow(ctx,
11 `INSERT INTO products (name, price_idr, stock, is_active)
12 VALUES ('Test', 100000, 5, true) RETURNING id`).
13 Scan(&productID)
14
15 // Create test user
16 var userID string
17 pool.QueryRow(ctx,
18 `INSERT INTO users (email, password_hash, name, is_active)
19 VALUES ('order-test@x.com', 'hash', 'Test', true) RETURNING id`).
20 Scan(&userID)
21
22 uid, _ := uuid.Parse(userID)
23 pid, _ := uuid.Parse(productID)
24 itemID := uuid.New()
25
26 order := &domainOrder.Order{
27 ID: uuid.New(),
28 UserID: uid,
29 Status: domainOrder.StatusPending,
30 Items: []domainOrder.OrderItem{{
31 ID: itemID, ProductID: pid,
32 ProductName: "Test", UnitPriceIDR: 100000, Quantity: 3,
33 }},
34 TotalAmountIDR: 300000,
35 PaymentMethod: domainOrder.PaymentQRIS,
36 CreatedAt: time.Now(),
37 UpdatedAt: time.Now(),
38 }
39
40 err := orderRepo.Create(ctx, order)
41 require.NoError(t, err)
42
43 // Verify stock was reserved
44 var reserved int
45 pool.QueryRow(ctx, "SELECT reserved_stock FROM products WHERE id = $1", productID).Scan(&reserved)
46 assert.Equal(t, 3, reserved)
47
48 // Verify outbox event was created
49 var eventCount int
50 pool.QueryRow(ctx, "SELECT COUNT(*) FROM outbox_events WHERE published = false").Scan(&eventCount)
51 assert.GreaterOrEqual(t, eventCount, 1)
52
53 // Cleanup
54 pool.Exec(ctx, "DELETE FROM orders WHERE id = $1", order.ID)
55 pool.Exec(ctx, "DELETE FROM products WHERE id = $1", productID)
56 pool.Exec(ctx, "DELETE FROM users WHERE id = $1", userID)
57}Memeriksa reserved_stock dan jumlah outbox_events dalam satu test memastikan bahwa keduanya benar-benar commit bersama order — bukan cuma di kode Go, tapi terverifikasi di baris database.
08.16 Order Response DTO
Response API harus lengkap dengan items dan harga snapshot. Kode berikut memetakan entity Order ke DTO JSON.
1type orderResponse struct {
2 ID string `json:"id"`
3 Status string `json:"status"`
4 Items []orderItem `json:"items"`
5 TotalAmountIDR int64 `json:"total_amount_idr"`
6 PaymentMethod string `json:"payment_method"`
7 PaymentURL *string `json:"payment_url,omitempty"`
8 CreatedAt string `json:"created_at"`
9 UpdatedAt string `json:"updated_at"`
10}
11
12type orderItem struct {
13 ProductID string `json:"product_id"`
14 ProductName string `json:"product_name"`
15 UnitPriceIDR int64 `json:"unit_price_idr"`
16 Quantity int `json:"quantity"`
17 SubtotalIDR int64 `json:"subtotal_idr"`
18}
19
20func toOrderResponse(o *domainOrder.Order) orderResponse {
21 items := make([]orderItem, len(o.Items))
22 for i, item := range o.Items {
23 items[i] = orderItem{
24 ProductID: item.ProductID.String(),
25 ProductName: item.ProductName,
26 UnitPriceIDR: item.UnitPriceIDR,
27 Quantity: item.Quantity,
28 SubtotalIDR: item.SubtotalIDR,
29 }
30 }
31 return orderResponse{
32 ID: o.ID.String(),
33 Status: string(o.Status),
34 Items: items,
35 TotalAmountIDR: o.TotalAmountIDR,
36 PaymentMethod: string(o.PaymentMethod),
37 PaymentURL: o.PaymentURL,
38 CreatedAt: o.CreatedAt.Format(time.RFC3339),
39 UpdatedAt: o.UpdatedAt.Format(time.RFC3339),
40 }
41}DTO terpisah dari entity domain memberi kita kebebasan mengubah representasi API tanpa mengganggu logika bisnis — misalnya menyembunyikan field internal atau memformat tanggal sesuai kebutuhan klien.
08.17 Error Propagation
Bagaimana error domain sampai ke response HTTP yang benar? Diagram berikut menelusuri satu skenario PlaceOrder dengan stok habis.
1Skenario PlaceOrder dengan multiple errors:
2
3User call POST /orders dengan product_id yang stok-nya habis:
4 Handler.PlaceOrder
5 → placeOrderUC.Execute
6 → productRepo.GetByID → nil, nil (product found)
7 → p.AvailableStock() = 0 < quantity
8 → return domain error: ErrInsufficientStock
9 → placeOrderUC returns: fmt.Errorf("placeOrder: %w", ErrInsufficientStock)
10 → apierror.Handle(err)
11 → errors.Is(err, product.ErrInsufficientStock) = true
12 → c.JSON(422, {"error": "INSUFFICIENT_STOCK", "detail": "product_id: xxx"})
13
14Chain: domain error → wrapped error → HTTP response
15errors.Is works through the fmt.Errorf wrap chain.Karena setiap error di-wrap dengan %w, errors.Is bisa menembus rantai wrapping untuk mencocokkan error domain — inilah yang memungkinkan mapping bersih dari error bisnis ke status HTTP.
08.18 Concurrent Order Scenario
Skenario paling kritis: dua user checkout produk terakhir bersamaan. Diagram berikut menunjukkan bagaimana row lock PostgreSQL menyerialkan kompetisi.
1Scenario: 2 user checkout produk dengan stok 1 secara bersamaan
2
3User A starts PlaceOrder:
4 tx1.Begin()
5 Check stock: AvailableStock() = 1 ✅
6 tx1.Exec: UPDATE products SET reserved = 1 WHERE id = X AND (stock - reserved) >= 1
7 → 1 row affected ✅
8
9User B starts PlaceOrder (concurrent with A):
10 tx2.Begin()
11 Check stock: AvailableStock() = 1 ✅ (tx1 not committed yet)
12 tx2.Exec: UPDATE products SET reserved = 1 WHERE id = X AND (stock - reserved) >= 1
13 → PostgreSQL row lock: tx2 waits for tx1 to commit
14
15tx1.Commit() → reserved = 1
16tx2's UPDATE: stock=1, reserved=1, available=0
17 → 0 rows affected
18 → tx2 returns ErrInsufficientStock
19 → tx2.Rollback
20
21Result: User A gets order. User B gets 422 INSUFFICIENT_STOCK.
22No overselling. No phantom data.Meski pengecekan awal AvailableStock() di keduanya lolos, UPDATE atomik yang menang hanya satu — PostgreSQL row lock menjamin tidak ada overselling walau race benar-benar terjadi.
08.19 Spec Coverage Summary
Setelah Order Service, mari petakan semua 13 AC dari 3 spec. Ringkasan berikut mengonfirmasi setiap AC beserta status code-nya.
1Setelah Artikel 08, semua 13 ACs dari 3 spec tercakup:
2
3place-order (5 ACs):
4 AC1: valid → 201 ✅
5 AC2: insufficient stock → 422 ✅
6 AC3: product not found → 404 ✅
7 AC4: unauthorized → 401 ✅ (middleware)
8 AC5: empty order → 400 ✅
9
10cancel-order (4 ACs):
11 AC1: PENDING + 30min window → 204 ✅
12 AC2: non-PENDING → 409 ✅
13 AC3: window expired → 409 ✅
14 AC4: not found → 404 ✅
15
16get-orders (4 ACs):
17 AC1: list with pagination → 200 ✅
18 AC2: filter by status → 200 filtered ✅
19 AC3: default pagination → 200 defaults ✅
20 AC4: user-scoped → implicit via repo query ✅
21
22specify audit score: ~80/100 (threshold: 80)
23Pipeline: PASS untuk spec compliance gate!Peta ini adalah bukti traceability SDD: setiap AC di spec bisa ditunjuk ke test dan status HTTP konkret — tidak ada AC yang “lupa” diimplementasi.
08.20 Ringkasan: Order Service Selesai
Order Service adalah jantung dari Santekno Shop. Empat pilar berikut merangkum yang kita bangun.
PlaceOrder: atomic create order + reserve stock + outbox event dalam satu DB transaction. No overselling possible.
CancelOrder: business rule di entity (CanBeCancelled()), atomic status update + stock restore.
GetOrders: user-scoped, pagination, optional status filter.
Outbox Pattern: event published reliably — event ada di DB sebelum Kafka. Worker retry jika Kafka down.
Specify audit ~80/100 — threshold tercapai. CI pipeline fully green. Berikutnya kita lengkapi lifecycle dengan Payment Service yang menutup empat AC terakhir.