Skip to content
Santekno.com | Level Up Your Engineering Skills
ID
📖 0%
13 Nov 2026 · 24 mnt baca ·Artikel 88 / 208
Go

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.

IH
Ihsan Arif
Penulis di Santekno · Backend Engineer

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.

go
 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.

go
  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.

go
 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.

go
 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.

go
 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.

go
 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.

go
  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.

go
 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.

go
 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.

go
 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.

text
 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 → safe

Dengan 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.

go
 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.

text
 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.

bash
 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 09

Score 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.

go
 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.

go
 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.

text
 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.

text
 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.

text
 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.

Artikel Terkait

💬 Komentar