Golang Order Service: Atomic PlaceOrder, CancelOrder, Outbox Pattern
The complete Santekno Shop Order Service. PlaceOrder with atomic stock reserve, CancelOrder with a 30-minute window, GetOrders pagination — all from the spec with a Kafka outbox pattern.
The Golang order service is the most complex article in Part 2 — the Order Service covers 13 Acceptance Criteria across 3 specs: PlaceOrder (5 ACs), CancelOrder (4 ACs), GetOrders (4 ACs). Two main challenges: atomic stock reserve when placing an order, and atomic stock restore + status update when cancelling, both guaranteed through a single DB transaction and the outbox pattern.
08.01 PlaceOrder UseCase: Full Implementation
The core use case starts from input validation and ends by building the Order entity. The code below validates each item, takes a price snapshot, then assembles the order before persisting it.
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 not found
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 — already inside the transaction in 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}The ProductName and UnitPriceIDR snapshots are taken when the order is created — so if an admin changes the price tomorrow, the old order history still shows the price the customer actually paid.
08.02 Order Repository: Create with Atomic Stock Reserve
Persisting an order must be atomic: insert the order, items, reserve stock, and outbox event all in one transaction. The code below wraps all four in a single 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: one transaction for 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 if commit succeeds
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 for each 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 // Insufficient stock — automatic rollback by 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 everything: 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}If any one item has insufficient stock (0 rows affected), defer tx.Rollback cancels the whole transaction — no partial order, no stock leaked into reservation.
08.03 CancelOrder UseCase
Cancelling an order combines a business rule (the 30-minute window) with atomic operations. The code below delegates the “can it be cancelled or not” decision to the 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: the domain entity decides whether cancel is allowed
27 if err := o.CanBeCancelled(); err != nil {
28 return err // ErrOrderNotCancellable or ErrCancelWindowExpired
29 }
30
31 // AC1: Atomic cancel + stock restore in one transaction
32 if err := uc.orderRepo.Cancel(ctx, orderID); err != nil {
33 return fmt.Errorf("cancelOrder.Execute: %w", err)
34 }
35
36 // Publish event async (doesn't block the 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}The GetByIDAndUserID query also enforces AC4: if the order doesn’t belong to that user, the result is nil — preventing user A from cancelling user B’s order without a separate authorization check.
08.04 Order Repository: Cancel with Atomic Stock Restore
At the repository layer, cancel must both return the reserved stock and change the status. The code below does both plus the outbox event in one transaction.
1// Cancel: one transaction for 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 to 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}The WHERE ... AND status = 'PENDING' condition acts as an anti-double-cancel guard: if the order was already cancelled before, 0 rows are affected and the operation stops safely without restoring stock twice.
08.05 GetOrders UseCase
Showing order history needs pagination and safe defaults. The code below sanitizes the input and computes the total number of pages.
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 is always passed to the repository, ensuring every order query is automatically scoped to the logged-in user — the basis of get-orders AC4.
08.06 GetByIDAndUserID and ListByUserID
These two repository queries enforce inter-user isolation. The code below always filters by user_id, both for a single order and for the 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}The user_id = $1 filter is in every query — data isolation happens at the database level, not in the application layer that’s more prone to being forgotten or bypassed.
08.07 Order Handler
The handler translates the HTTP request into use case input, complete with struct validation and authorization via middleware. The code below covers all three order endpoints.
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}The max=10,dive validation tag on Items enforces the business rule right at the handler layer — a request with more than 10 items is rejected with 400 before touching the use case or database.
08.08 PlaceOrder Tests
All five PlaceOrder ACs must be covered by tests. The suite below tests the success path down to every error condition.
1// internal/usecase/order/place_order_test.go
2
3// AC1: Order created successfully
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: Insufficient stock
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 not found
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}Every test uses s.ErrorIs to verify a specific domain error — not just “there’s an error” — so the mapping to HTTP status (422, 404, 400) is genuinely guaranteed.
08.09 CancelOrder Tests
CancelOrder has four ACs that depend on time and status. The tests below manipulate CreatedAt to test the 30-minute window.
1// internal/usecase/order/cancel_order_test.go
2
3// AC1: Cancel a PENDING order within the 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), // within the 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 is not 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 already expired
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), // past the 30 minutes
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 not found
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}Because the window rule lives in the entity, we can test expiration simply by setting CreatedAt to the past — with no need to manipulate the system clock or touch the database.
08.10 GetOrders Tests
GetOrders is tested for pagination, status filtering, and user isolation. The suite below asserts the arguments the repository receives.
1// internal/usecase/order/get_orders_test.go
2
3// AC1: List orders with 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 only sees their own orders
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}The ScopedToUser test verifies that specificUserID is genuinely passed to the repository — proof that AC4 (inter-user isolation) is guarded by the contract, not by coincidence.
08.11 Price Snapshot Pattern
Why must the price be snapshotted? The diagram below explains the danger of recomputing the price from the product table when showing order history.
1WHY IS THE PRICE SNAPSHOT IMPORTANT?
2
3Scenario without snapshot:
4 Customer buys a product for Rp 100,000
5 Admin updates the price to Rp 150,000
6 Customer sees the receipt: Rp 150,000 ???
7
8WITH a price snapshot in order_items:
9 order_items.unit_price_idr = price AT ORDER CREATION TIME
10 products.price_idr can change → doesn't affect old orders
11
12Implementation:
13 On PlaceOrder:
14 item.ProductName = p.Name // snapshot the name
15 item.UnitPriceIDR = p.PriceIDR // snapshot the price
16 item.SubtotalIDR = item.UnitPriceIDR * qty
17
18 After the order is created, the products table can update → safeBy storing the price in order_items, the purchase history becomes immutable — a future product price change never alters the value of an order that already happened.
08.12 Idempotency Pattern for PlaceOrder
PlaceOrder is not naturally idempotent — a double-click can create two orders. The code below adds a Redis-based idempotency key.
1// PlaceOrder is NOT naturally idempotent
2// A double-click can create 2 orders!
3// Solution: idempotency key from the client
4
5// Client sends header: Idempotency-Key: <uuid>
6// Server stores: 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}Notice the different status codes: the first request returns 201 Created, while a retry with the same key returns 200 OK with the same order — the client can retry safely with no risk of duplication.
08.13 Outbox Pattern: Why It Matters
The outbox pattern guarantees events are delivered even if Kafka is down. The diagram below compares the flow without and with the outbox.
1WITHOUT OUTBOX:
2 tx.Commit() → order created in DB ✅
3 publisher.Publish() → Kafka publish fails ❌
4
5 The order exists in the DB, but the event is never sent.
6 No notification is sent.
7 No payment is initiated.
8
9 How to fix: manual retry, or the event is lost forever.
10
11WITH OUTBOX:
12 tx.Begin()
13 INSERT order
14 INSERT order_items
15 UPDATE products (reserve stock)
16 INSERT outbox_events ← event committed together with the order
17 tx.Commit()
18
19 Background worker:
20 SELECT * FROM outbox_events WHERE published = false
21 → Publish to Kafka
22 → UPDATE outbox_events SET published = true
23
24 If Kafka is down:
25 - The order is still saved ✅
26 - outbox_events stays published = false
27 - The worker retries automatically when Kafka is back UP
28
29 At-least-once delivery:
30 The Kafka consumer MUST be idempotent (Articles 10, 09).The key to the outbox: the event is written to the DB in the same transaction as the order, so there will never be an order without its event — the “dual write” problem between the DB and the message broker is solved.
08.14 Specify Audit Score After Article 08
The Order Service closes most of the remaining ACs. The audit command below shows the score reaching the CI threshold.
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", ← Article 06
11# "user-login", ← Article 06
12# "product-catalog", ← Article 07
13# "place-order", ← Article 08 (5 ACs)
14# "cancel-order", ← Article 08 (4 ACs)
15# "get-orders" ← Article 08 (4 ACs)
16# ]
17# }
18#
19# 80/100 → CI pipeline pass!
20# Payment-webhook (4 ACs) still uncovered → Article 09A score of 80/100 is an important moment: for the first time, the CI quality gate for spec compliance passes — proof that the three core services genuinely satisfy the specification.
08.15 Integration Test: PlaceOrder
The integration test proves atomicity against a real PostgreSQL. The test below verifies that stock is reserved and the outbox event is created within a single 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}Checking reserved_stock and the outbox_events count in a single test ensures both genuinely commit together with the order — not just in Go code, but verified at the database rows.
08.16 Order Response DTO
The API response must be complete with items and price snapshots. The code below maps the Order entity to a JSON DTO.
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}Keeping the DTO separate from the domain entity gives us the freedom to change the API representation without disturbing business logic — for example, hiding internal fields or formatting dates to the client’s needs.
08.17 Error Propagation
How does a domain error reach the correct HTTP response? The diagram below traces one PlaceOrder scenario with sold-out stock.
1PlaceOrder scenario with multiple errors:
2
3User calls POST /orders with a product_id that's out of stock:
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.Because every error is wrapped with %w, errors.Is can penetrate the wrapping chain to match the domain error — this is what enables a clean mapping from business errors to HTTP status codes.
08.18 Concurrent Order Scenario
The most critical scenario: two users checking out the last unit at the same time. The diagram below shows how the PostgreSQL row lock serializes the competition.
1Scenario: 2 users check out a product with stock 1 at the same time
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 the order. User B gets 422 INSUFFICIENT_STOCK.
22No overselling. No phantom data.Even though the initial AvailableStock() check passes in both, only one atomic UPDATE wins — the PostgreSQL row lock guarantees no overselling even when the race genuinely happens.
08.19 Spec Coverage Summary
After the Order Service, let’s map all 13 ACs from the 3 specs. The summary below confirms each AC along with its status code.
1After Article 08, all 13 ACs from the 3 specs are covered:
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 for the spec compliance gate!This map is proof of SDD traceability: every AC in the spec can be pointed to a concrete test and HTTP status — no AC is “forgotten” in the implementation.
08.20 Summary: Order Service Done
The Order Service is the heart of Santekno Shop. The four pillars below summarize what we built.
PlaceOrder: atomic create order + reserve stock + outbox event in a single DB transaction. No overselling possible.
CancelOrder: the business rule in the entity (CanBeCancelled()), atomic status update + stock restore.
GetOrders: user-scoped, pagination, optional status filter.
Outbox Pattern: events published reliably — the event is in the DB before Kafka. The worker retries if Kafka is down.
The specify audit is ~80/100 — threshold reached. The CI pipeline is fully green. Next we complete the lifecycle with the Payment Service that closes the final four ACs.