Golang Product Service: Full-Text Search PostgreSQL tsvector, Inventory Clean Architecture
Implementasi Product Service Santekno Shop. ListProducts, GetProduct, SearchProducts dengan PostgreSQL full-text search tsvector, stock management, dan pgvector column siap AI.
Golang product service dengan PostgreSQL full-text search adalah fondasi katalog e-commerce yang bisa diandalkan. Dari spec Artikel 01: product catalog (4 ACs) mencakup list products dengan filter, full-text search, dan detail produk. Di artikel ini kita implementasi lengkap Product Repository dengan PostgreSQL tsvector search, optimistic locking untuk stock management, dan pgvector column yang sudah siap untuk Topik 6.
07.01 ListProducts UseCase
UseCase pertama adalah ListProducts — pintu masuk untuk menampilkan katalog dengan pagination yang aman. Kode berikut menyanitasi input pagination sebelum meneruskannya ke repository.
1// internal/usecase/product/list_products.go
2
3package product
4
5import (
6 "context"
7 "fmt"
8 "math"
9
10 domainProduct "github.com/santekno/santekno-shop/internal/domain/product"
11)
12
13type ListProductsOutput struct {
14 Products []*domainProduct.Product
15 Total int
16 Page int
17 Limit int
18 TotalPages int
19}
20
21func (uc *ListProductsUseCase) Execute(ctx context.Context, filter domainProduct.SearchFilter) (*ListProductsOutput, error) {
22 // Sanitize pagination
23 if filter.Page < 1 { filter.Page = 1 }
24 if filter.Limit < 1 { filter.Limit = 10 }
25 if filter.Limit > 50 { filter.Limit = 50 }
26
27 products, total, err := uc.productRepo.List(ctx, filter)
28 if err != nil {
29 return nil, fmt.Errorf("listProducts.Execute: %w", err)
30 }
31
32 totalPages := int(math.Ceil(float64(total) / float64(filter.Limit)))
33 if totalPages < 1 { totalPages = 1 }
34
35 return &ListProductsOutput{
36 Products: products,
37 Total: total,
38 Page: filter.Page,
39 Limit: filter.Limit,
40 TotalPages: totalPages,
41 }, nil
42}Dengan membatasi Limit maksimal 50 dan default 10, kita mencegah klien memaksa query berat sekaligus menjaga response tetap ringan.
07.02 GetProduct UseCase
UseCase kedua mengambil satu produk berdasarkan ID. Perhatikan bagaimana kode berikut membedakan “tidak ditemukan” dari “produk inactive”.
1// internal/usecase/product/get_product.go
2
3package product
4
5import (
6 "context"
7 "fmt"
8
9 domainProduct "github.com/santekno/santekno-shop/internal/domain/product"
10)
11
12func (uc *GetProductUseCase) Execute(ctx context.Context, id string) (*domainProduct.Product, error) {
13 p, err := uc.productRepo.GetByID(ctx, id)
14 if err != nil {
15 return nil, fmt.Errorf("getProduct.Execute: %w", err)
16 }
17 // AC3: Product tidak ada → return nil → handler beri 404
18 if p == nil {
19 return nil, domainProduct.ErrProductNotFound
20 }
21 // AC4: produk inactive masih di-return (stock = 0)
22 return p, nil
23}Keputusan penting di sini: produk inactive tetap dikembalikan (sesuai AC4), hanya produk yang benar-benar tidak ada yang memicu ErrProductNotFound.
07.03 PostgreSQL Product Repository: List dengan Full-Text Search
Inilah inti Product Service — repository yang membangun query dinamis dengan full-text search tsvector. Kode berikut menyusun kondisi WHERE secara bertahap agar aman dari SQL injection.
1// internal/repository/postgres/product_repository.go
2
3package postgres
4
5import (
6 "context"
7 "fmt"
8 "strings"
9
10 "github.com/jackc/pgx/v5"
11 "github.com/jackc/pgx/v5/pgxpool"
12
13 domainProduct "github.com/santekno/santekno-shop/internal/domain/product"
14 "github.com/santekno/santekno-shop/pkg/pagination"
15)
16
17type ProductRepository struct {
18 db *pgxpool.Pool
19}
20
21func NewProductRepository(db *pgxpool.Pool) *ProductRepository {
22 return &ProductRepository{db: db}
23}
24
25func (r *ProductRepository) List(ctx context.Context, filter domainProduct.SearchFilter) ([]*domainProduct.Product, int, error) {
26 // Build dynamic query
27 args := []interface{}{}
28 conditions := []string{"p.is_active = true"}
29 argIdx := 1
30
31 // AC2: Full-text search menggunakan tsvector
32 if filter.Query != "" {
33 conditions = append(conditions, fmt.Sprintf(
34 "p.search_vector @@ plainto_tsquery('indonesian', $%d)", argIdx))
35 args = append(args, filter.Query)
36 argIdx++
37 }
38
39 // AC1: Filter by category
40 if filter.CategoryID != nil {
41 conditions = append(conditions, fmt.Sprintf("p.category_id = $%d", argIdx))
42 args = append(args, filter.CategoryID)
43 argIdx++
44 }
45
46 // AC1: Filter by price range
47 if filter.MinPriceIDR != nil {
48 conditions = append(conditions, fmt.Sprintf("p.price_idr >= $%d", argIdx))
49 args = append(args, *filter.MinPriceIDR)
50 argIdx++
51 }
52 if filter.MaxPriceIDR != nil {
53 conditions = append(conditions, fmt.Sprintf("p.price_idr <= $%d", argIdx))
54 args = append(args, *filter.MaxPriceIDR)
55 argIdx++
56 }
57
58 whereClause := "WHERE " + strings.Join(conditions, " AND ")
59
60 // Count total
61 countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM products p %s`, whereClause)
62 var total int
63 if err := r.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
64 return nil, 0, fmt.Errorf("productRepo.List count: %w", err)
65 }
66
67 // Fetch page
68 offset := pagination.Offset(filter.Page, filter.Limit)
69 args = append(args, filter.Limit, offset)
70
71 // Order by relevance jika ada query, otherwise by created_at
72 orderClause := "ORDER BY p.created_at DESC"
73 if filter.Query != "" {
74 orderClause = fmt.Sprintf(
75 "ORDER BY ts_rank(p.search_vector, plainto_tsquery('indonesian', $1)) DESC")
76 }
77
78 dataQuery := fmt.Sprintf(`
79 SELECT p.id, p.category_id, p.name, p.description, p.price_idr,
80 p.stock, p.reserved_stock, p.is_active, p.created_at, p.updated_at
81 FROM products p
82 %s
83 %s
84 LIMIT $%d OFFSET $%d`,
85 whereClause, orderClause, argIdx, argIdx+1)
86
87 rows, err := r.db.Query(ctx, dataQuery, args...)
88 if err != nil {
89 return nil, 0, fmt.Errorf("productRepo.List query: %w", err)
90 }
91 defer rows.Close()
92
93 var products []*domainProduct.Product
94 for rows.Next() {
95 var p domainProduct.Product
96 if err := rows.Scan(
97 &p.ID, &p.CategoryID, &p.Name, &p.Description, &p.PriceIDR,
98 &p.Stock, &p.ReservedStock, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
99 ); err != nil {
100 return nil, 0, fmt.Errorf("productRepo.List scan: %w", err)
101 }
102 products = append(products, &p)
103 }
104
105 if err := rows.Err(); err != nil {
106 return nil, 0, fmt.Errorf("productRepo.List rows: %w", err)
107 }
108
109 return products, total, nil
110}Pola membangun conditions sebagai slice lalu menggabungkannya dengan placeholder bernomor ($1, $2, …) memastikan seluruh input user di-bind sebagai parameter — bukan di-concat ke string SQL, sehingga injection tertutup rapat.
07.04 GetByID dan Stock Management
Setelah List, kita butuh GetByID dan operasi stok. Kode berikut menampilkan pola optimistic locking untuk reserve dan restore stok tanpa SELECT terpisah.
1func (r *ProductRepository) GetByID(ctx context.Context, id string) (*domainProduct.Product, error) {
2 var p domainProduct.Product
3 err := r.db.QueryRow(ctx,
4 `SELECT id, category_id, name, description, price_idr,
5 stock, reserved_stock, is_active, created_at, updated_at
6 FROM products WHERE id = $1`,
7 id,
8 ).Scan(
9 &p.ID, &p.CategoryID, &p.Name, &p.Description, &p.PriceIDR,
10 &p.Stock, &p.ReservedStock, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
11 )
12 if err != nil {
13 if err == pgx.ErrNoRows { return nil, nil } // not found
14 return nil, fmt.Errorf("productRepo.GetByID: %w", err)
15 }
16 return &p, nil
17}
18
19// ReserveStock: optimistic locking pattern
20// UPDATE ... WHERE stock - reserved_stock >= qty
21// Jika 0 rows affected → insufficient stock
22func (r *ProductRepository) ReserveStock(ctx context.Context, id string, qty int) error {
23 result, err := r.db.Exec(ctx,
24 `UPDATE products
25 SET reserved_stock = reserved_stock + $1, updated_at = NOW()
26 WHERE id = $2
27 AND (stock - reserved_stock) >= $1
28 AND is_active = true`,
29 qty, id,
30 )
31 if err != nil {
32 return fmt.Errorf("productRepo.ReserveStock: %w", err)
33 }
34 if result.RowsAffected() == 0 {
35 return domainProduct.ErrInsufficientStock
36 }
37 return nil
38}
39
40// RestoreStock: kembalikan reserved ke available (pada cancel order)
41func (r *ProductRepository) RestoreStock(ctx context.Context, id string, qty int) error {
42 _, err := r.db.Exec(ctx,
43 `UPDATE products
44 SET reserved_stock = GREATEST(0, reserved_stock - $1), updated_at = NOW()
45 WHERE id = $2`,
46 qty, id,
47 )
48 if err != nil {
49 return fmt.Errorf("productRepo.RestoreStock: %w", err)
50 }
51 return nil
52}Kunci ReserveStock: pengecekan (stock - reserved_stock) >= $1 dilakukan di dalam UPDATE yang sama, jadi tidak ada celah race antara “cek stok” dan “kurangi stok” — atomicity dijamin PostgreSQL.
07.05 Product Handler
Layer handler menerjemahkan query param HTTP menjadi filter usecase. Kode berikut mem-parse parameter opsional seperti kategori dan rentang harga dengan validasi.
1// delivery/http/handler/product_handler.go
2
3package handler
4
5import (
6 "net/http"
7
8 "github.com/google/uuid"
9 "github.com/labstack/echo/v4"
10 "github.com/santekno/santekno-shop/internal/domain/product"
11 "github.com/santekno/santekno-shop/internal/usecase/product"
12 "github.com/santekno/santekno-shop/pkg/apierror"
13 "github.com/santekno/santekno-shop/pkg/pagination"
14)
15
16func (h *ProductHandler) List(c echo.Context) error {
17 p := pagination.ParseParams(c)
18
19 filter := product.SearchFilter{
20 Query: c.QueryParam("q"),
21 Page: p.Page,
22 Limit: p.Limit,
23 }
24
25 // Optional category filter
26 if catStr := c.QueryParam("category"); catStr != "" {
27 catID, err := uuid.Parse(catStr)
28 if err != nil {
29 return c.JSON(http.StatusBadRequest, apierror.Response{Error: "INVALID_CATEGORY_ID"})
30 }
31 filter.CategoryID = &catID
32 }
33
34 // Optional price filter
35 if minStr := c.QueryParam("min_price"); minStr != "" {
36 var minPrice int64
37 if _, err := fmt.Sscan(minStr, &minPrice); err == nil && minPrice >= 0 {
38 filter.MinPriceIDR = &minPrice
39 }
40 }
41 if maxStr := c.QueryParam("max_price"); maxStr != "" {
42 var maxPrice int64
43 if _, err := fmt.Sscan(maxStr, &maxPrice); err == nil && maxPrice >= 0 {
44 filter.MaxPriceIDR = &maxPrice
45 }
46 }
47
48 out, err := h.listUC.Execute(c.Request().Context(), filter)
49 if err != nil {
50 return apierror.Handle(c, err)
51 }
52
53 return c.JSON(http.StatusOK, map[string]interface{}{
54 "data": toProductResponses(out.Products),
55 "pagination": pagination.NewMeta(out.Page, out.Limit, out.Total),
56 })
57}
58
59func (h *ProductHandler) Get(c echo.Context) error {
60 id := c.Param("id")
61 if _, err := uuid.Parse(id); err != nil {
62 return c.JSON(http.StatusBadRequest, apierror.Response{Error: "INVALID_PRODUCT_ID"})
63 }
64
65 p, err := h.getUC.Execute(c.Request().Context(), id)
66 if err != nil {
67 return apierror.Handle(c, err)
68 }
69
70 return c.JSON(http.StatusOK, toProductResponse(p))
71}
72
73// Response DTOs
74type productResponse struct {
75 ID string `json:"id"`
76 Name string `json:"name"`
77 Description string `json:"description,omitempty"`
78 PriceIDR int64 `json:"price_idr"`
79 Stock int `json:"stock"`
80 IsActive bool `json:"is_active"`
81 CreatedAt string `json:"created_at"`
82}
83
84func toProductResponse(p *product.Product) productResponse {
85 return productResponse{
86 ID: p.ID.String(),
87 Name: p.Name,
88 Description: p.Description,
89 PriceIDR: p.PriceIDR,
90 Stock: p.AvailableStock(), // show available, not total
91 IsActive: p.IsActive,
92 CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"),
93 }
94}
95
96func toProductResponses(products []*product.Product) []productResponse {
97 resp := make([]productResponse, len(products))
98 for i, p := range products { resp[i] = toProductResponse(p) }
99 return resp
100}Perhatikan toProductResponse mengembalikan AvailableStock() bukan Stock mentah — customer melihat stok yang benar-benar bisa dibeli, bukan angka yang sebagian sudah di-reserve order lain.
07.06 Full-Text Search: tsvector Setup
Full-text search Indonesia bergantung pada kolom tsvector dan dictionary yang tepat. Skrip SQL berikut mengisi ulang search_vector dan memverifikasi trigger dari migration sebelumnya.
1-- migrations/20260701000007_add_search_vector.up.sql
2
3-- Pastikan PostgreSQL dictionary 'indonesian' tersedia
4-- (alternatif: gunakan 'simple' untuk testing)
5
6-- Isi search_vector untuk produk existing
7UPDATE products
8SET search_vector = to_tsvector('indonesian',
9 COALESCE(name, '') || ' ' || COALESCE(description, ''));
10
11-- Trigger sudah ada dari migration Artikel 02
12-- (products_search_vector_trigger)
13-- Tapi kita verify:
14SELECT tgname FROM pg_trigger WHERE tgrelid = 'products'::regclass;
15-- Expected: products_search_vector_trigger
16
17-- Test search:
18SELECT name, price_idr
19FROM products
20WHERE search_vector @@ plainto_tsquery('indonesian', 'sepatu lari')
21ORDER BY ts_rank(search_vector, plainto_tsquery('indonesian', 'sepatu lari')) DESC;Dengan trigger yang mengisi search_vector otomatis di setiap insert/update, kita tidak perlu me-refresh index secara manual — pencarian selalu konsisten dengan data terbaru.
07.07 Product Tests
Semua AC wajib punya test. Suite berikut memakai testify + gomock untuk memverifikasi pagination, default value, dan pass-through query ke repository.
1// internal/usecase/product/list_products_test.go
2
3package product_test
4
5import (
6 "context"
7 "testing"
8
9 "github.com/stretchr/testify/suite"
10 "go.uber.org/mock/gomock"
11
12 domainProduct "github.com/santekno/santekno-shop/internal/domain/product"
13 productUC "github.com/santekno/santekno-shop/internal/usecase/product"
14 "github.com/santekno/santekno-shop/testutil/mock"
15)
16
17type ListProductsSuite struct {
18 suite.Suite
19 ctrl *gomock.Controller
20 productRepo *mock.MockProductRepository
21 uc *productUC.ListProductsUseCase
22}
23
24func (s *ListProductsSuite) SetupTest() {
25 s.ctrl = gomock.NewController(s.T())
26 s.productRepo = mock.NewMockProductRepository(s.ctrl)
27 s.uc = productUC.NewListProductsUseCase(s.productRepo)
28}
29
30// AC1: List products dengan pagination
31func (s *ListProductsSuite) TestListProducts_ValidFilter_ReturnsPaginatedProducts() {
32 mockProducts := []*domainProduct.Product{
33 {Name: "Sepatu Lari A", PriceIDR: 500_000_00, Stock: 10},
34 {Name: "Sepatu Lari B", PriceIDR: 750_000_00, Stock: 5},
35 }
36
37 s.productRepo.EXPECT().
38 List(gomock.Any(), gomock.Any()).
39 Return(mockProducts, 2, nil)
40
41 out, err := s.uc.Execute(context.Background(), domainProduct.SearchFilter{
42 Page: 1, Limit: 10,
43 })
44
45 s.NoError(err)
46 s.Len(out.Products, 2)
47 s.Equal(2, out.Total)
48 s.Equal(1, out.TotalPages)
49}
50
51// AC3: Default pagination values
52func (s *ListProductsSuite) TestListProducts_ZeroPage_DefaultsToPage1() {
53 s.productRepo.EXPECT().
54 List(gomock.Any(), gomock.AssignableToTypeOf(domainProduct.SearchFilter{})).
55 DoAndReturn(func(_ context.Context, f domainProduct.SearchFilter) ([]*domainProduct.Product, int, error) {
56 s.Equal(1, f.Page) // default
57 s.Equal(10, f.Limit) // default
58 return nil, 0, nil
59 })
60
61 _, err := s.uc.Execute(context.Background(), domainProduct.SearchFilter{Page: 0, Limit: 0})
62 s.NoError(err)
63}
64
65// AC2: Search filter applied
66func (s *ListProductsSuite) TestListProducts_WithQuery_PassesQueryToRepo() {
67 s.productRepo.EXPECT().
68 List(gomock.Any(), gomock.AssignableToTypeOf(domainProduct.SearchFilter{})).
69 DoAndReturn(func(_ context.Context, f domainProduct.SearchFilter) ([]*domainProduct.Product, int, error) {
70 s.Equal("sepatu lari", f.Query)
71 return nil, 0, nil
72 })
73
74 _, err := s.uc.Execute(context.Background(), domainProduct.SearchFilter{Query: "sepatu lari"})
75 s.NoError(err)
76}
77
78func TestListProductsSuite(t *testing.T) {
79 suite.Run(t, new(ListProductsSuite))
80}Pola DoAndReturn memungkinkan kita menegaskan nilai yang diterima repository — bukan sekadar hasil akhir — sehingga logika default pagination benar-benar teruji, bukan diasumsikan.
07.08 Get Product Tests
Untuk GetProduct, dua kasus krusial adalah “tidak ditemukan” dan “inactive”. Test berikut memastikan keduanya diperlakukan berbeda sesuai spec.
1// internal/usecase/product/get_product_test.go
2
3// AC3: Product tidak ditemukan
4func (s *GetProductSuite) TestGetProduct_NotFound_ReturnsProductNotFound() {
5 s.productRepo.EXPECT().
6 GetByID(gomock.Any(), "non-existent-uuid").
7 Return(nil, nil) // nil, nil = not found
8
9 _, err := s.uc.Execute(context.Background(), "non-existent-uuid")
10 s.ErrorIs(err, domainProduct.ErrProductNotFound)
11}
12
13// AC4: Product inactive masih di-return (stock = 0)
14func (s *GetProductSuite) TestGetProduct_Inactive_ReturnsWithStock0() {
15 inactiveProduct := &domainProduct.Product{
16 Name: "Produk Inactive",
17 PriceIDR: 100_000_00,
18 Stock: 0,
19 IsActive: false,
20 }
21 s.productRepo.EXPECT().
22 GetByID(gomock.Any(), "some-uuid").
23 Return(inactiveProduct, nil)
24
25 p, err := s.uc.Execute(context.Background(), "some-uuid")
26 s.NoError(err)
27 s.NotNil(p) // tetap di-return
28 s.Equal(0, p.Stock) // stock 0
29 s.False(p.IsActive) // status inactive
30}Test AC4 mengunci kontrak penting: backend tidak menyembunyikan produk inactive — keputusan menampilkan badge “Tidak Tersedia” diserahkan ke frontend.
07.09 pgvector Column: AI-Ready dari Hari Pertama
Salah satu keputusan arsitektur paling strategis adalah menyiapkan kolom embedding sejak awal. Skrip berikut menunjukkan rencana pemakaian pgvector untuk semantic search di topik mendatang.
1-- Kolom embedding sudah ada dari migration Artikel 02:
2-- embedding VECTOR(1536) -- untuk OpenAI text-embedding-ada-002
3
4-- Di Topik 6, kita akan mengisi kolom ini:
5-- UPDATE products SET embedding = $1 WHERE id = $2
6
7-- Di Topik 7, kita akan menggunakannya untuk semantic search:
8-- SELECT * FROM products
9-- ORDER BY embedding <=> $1::vector -- cosine distance
10-- LIMIT 10
11
12-- Index untuk semantic search (akan di-create Topik 6):
13-- CREATE INDEX CONCURRENTLY idx_products_embedding
14-- ON products USING ivfflat(embedding vector_cosine_ops)
15-- WITH (lists = 100);
16
17-- Verifikasi extension tersedia:
18SELECT * FROM pg_extension WHERE extname = 'vector';Dengan kolom embedding sudah ada dari hari pertama, migrasi ke semantic search di Topik 6 tidak butuh perubahan skema sama sekali — cukup mengisi data dan membuat index.
07.10 Seed Data untuk Development
Development butuh data uji yang realistis. Skrip seed berikut mengisi kategori dan produk contoh, lalu memverifikasi search_vector terisi otomatis.
1-- testdata/seed_products.sql
2-- Run: psql "$DATABASE_URL" < testdata/seed_products.sql
3
4INSERT INTO categories (id, name, slug) VALUES
5 ('cat-electronics-uuid', 'Electronics', 'electronics'),
6 ('cat-fashion-uuid', 'Fashion & Apparel', 'fashion'),
7 ('cat-sport-uuid', 'Sports & Fitness', 'sports');
8
9INSERT INTO products (id, category_id, name, description, price_idr, stock, is_active) VALUES
10 (
11 'prod-001-uuid',
12 'cat-electronics-uuid',
13 'Smartphone Santekno X1',
14 'Smartphone flagship dengan kamera 200MP dan baterai 6000mAh',
15 599_999_900, -- Rp 5.999.999
16 50,
17 true
18 ),
19 (
20 'prod-002-uuid',
21 'cat-sport-uuid',
22 'Sepatu Lari Santekno Pro',
23 'Sepatu lari ringan dengan teknologi bantalan udara terbaru',
24 89_999_900, -- Rp 899.999
25 200,
26 true
27 ),
28 (
29 'prod-003-uuid',
30 'cat-fashion-uuid',
31 'Kemeja Batik Santekno Classic',
32 'Kemeja batik premium dengan motif nusantara, bahan katun premium',
33 29_999_900, -- Rp 299.999
34 100,
35 true
36 );
37
38-- Verify search vector terisi via trigger:
39SELECT name, search_vector IS NOT NULL AS has_vector FROM products;Query verifikasi di akhir memastikan trigger tsvector benar-benar bekerja pada data seed — jika has_vector false, berarti trigger belum terpasang dengan benar.
07.11 Repository Integration Test
Unit test saja tidak cukup untuk memvalidasi query SQL nyata. Integration test berikut menjalankan search dan reserve stock terhadap PostgreSQL asli.
1// internal/repository/postgres/product_repository_integration_test.go
2//go:build integration
3
4func TestProductRepository_List_WithSearch(t *testing.T) {
5 pool := setupDB(t)
6 repo := postgres.NewProductRepository(pool)
7 ctx := context.Background()
8
9 // Seed test data
10 _, err := pool.Exec(ctx,
11 `INSERT INTO products (name, description, price_idr, stock, is_active)
12 VALUES ($1, $2, $3, $4, $5)`,
13 "Sepatu Lari Test", "Sepatu ringan untuk lari", 89_999_900, 10, true)
14 require.NoError(t, err)
15
16 // Test full-text search
17 products, total, err := repo.List(ctx, product.SearchFilter{
18 Query: "sepatu lari", Page: 1, Limit: 10,
19 })
20 require.NoError(t, err)
21 assert.GreaterOrEqual(t, total, 1)
22 assert.GreaterOrEqual(t, len(products), 1)
23
24 // Cleanup
25 pool.Exec(ctx, "DELETE FROM products WHERE name = 'Sepatu Lari Test'")
26}
27
28func TestProductRepository_ReserveStock_Success(t *testing.T) {
29 pool := setupDB(t)
30 repo := postgres.NewProductRepository(pool)
31 ctx := context.Background()
32
33 // Create test product with 10 stock
34 var productID string
35 pool.QueryRow(ctx,
36 `INSERT INTO products (name, price_idr, stock, is_active)
37 VALUES ('Test Product', 100000, 10, true) RETURNING id`,
38 ).Scan(&productID)
39
40 err := repo.ReserveStock(ctx, productID, 3)
41 assert.NoError(t, err)
42
43 // Verify reserved_stock increased
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 // Cleanup
49 pool.Exec(ctx, "DELETE FROM products WHERE id = $1", productID)
50}
51
52func TestProductRepository_ReserveStock_InsufficientStock(t *testing.T) {
53 pool := setupDB(t)
54 repo := postgres.NewProductRepository(pool)
55 ctx := context.Background()
56
57 var productID string
58 pool.QueryRow(ctx,
59 `INSERT INTO products (name, price_idr, stock, is_active)
60 VALUES ('Low Stock', 100000, 2, true) RETURNING id`,
61 ).Scan(&productID)
62
63 err := repo.ReserveStock(ctx, productID, 5) // request 5, only 2 available
64 assert.ErrorIs(t, err, product.ErrInsufficientStock) // optimistic lock fail
65
66 pool.Exec(ctx, "DELETE FROM products WHERE id = $1", productID)
67}Integration test ReserveStock_InsufficientStock membuktikan optimistic lock benar-benar menolak reservasi berlebih di level database — bukan hanya di logika Go yang bisa saja salah asumsi.
07.12 Caching Strategy (Preview)
Untuk katalog high-traffic, cache adalah kebutuhan. Kode berikut adalah preview pola CachedProductRepository yang akan diimplementasi penuh di Artikel 13.
1// Preview: Redis caching untuk product list
2// Akan diimplementasi di Artikel 13 (Redis Caching Strategy)
3
4// Untuk sekarang, product repository langsung ke DB.
5// Di Artikel 13, kita wrap dengan Redis cache:
6
7type CachedProductRepository struct {
8 repo domainProduct.Repository
9 cache *redis.Client
10}
11
12func (r *CachedProductRepository) GetByID(ctx context.Context, id string) (*domainProduct.Product, error) {
13 key := "product:" + id
14
15 // Try cache first
16 cached, err := r.cache.Get(ctx, key).Bytes()
17 if err == nil {
18 var p domainProduct.Product
19 json.Unmarshal(cached, &p)
20 return &p, nil
21 }
22
23 // Cache miss: fetch from DB
24 p, err := r.repo.GetByID(ctx, id)
25 if err != nil || p == nil { return p, err }
26
27 // Store in cache (TTL 5 menit)
28 data, _ := json.Marshal(p)
29 r.cache.Set(ctx, key, data, 5*time.Minute)
30
31 return p, nil
32}Pola decorator ini menjaga interface Repository tetap sama — cache ditambahkan sebagai lapisan pembungkus, jadi usecase tidak perlu tahu apakah data datang dari Redis atau PostgreSQL.
07.13 Specify Audit Setelah Artikel 07
Setiap artikel menaikkan spec coverage. Perintah specify audit berikut menunjukkan fitur mana saja yang sudah tercakup setelah Product Service selesai.
1specify audit --output json | jq '{
2 score: .score,
3 covered: [.features[] | select(.status == "covered") | .feature]
4}'
5
6# Expected setelah Artikel 07:
7# {
8# "score": 50,
9# "covered": [
10# "user-registration", ← Artikel 06
11# "user-login", ← Artikel 06
12# "product-catalog" ← Artikel 07 (baru!)
13# ]
14# }Score naik dari 35 ke 50 setelah product-catalog tercakup — bukti terukur bahwa implementasi benar-benar memenuhi spec, bukan sekadar “kelihatannya jalan”.
07.14 SearchProducts: AC2 Detail
Mari perdalam AC2 tentang full-text search. Contoh berikut memetakan query HTTP ke query database dan menunjukkan cara mengetesnya dengan curl.
1# AC2: "Search menggunakan full-text search"
2# Query: GET /api/v1/products?q=sepatu+lari
3# Database: WHERE search_vector @@ plainto_tsquery('indonesian', 'sepatu lari')
4# Sorted: ORDER BY ts_rank(search_vector, ...) DESC
5
6# Test dengan curl:
7curl "http://localhost:8080/api/v1/products?q=sepatu+lari"
8
9# Expected response:
10# {
11# "data": [
12# {"id": "...", "name": "Sepatu Lari Santekno Pro", ...},
13# ],
14# "pagination": {"page": 1, "limit": 10, "total": 1, "total_pages": 1}
15# }
16
17# Test dengan filter harga:
18curl "http://localhost:8080/api/v1/products?min_price=50000000&max_price=100000000"
19# Products antara Rp 500.000 - Rp 1.000.000 (dalam cents)Perhatikan hasil search sudah diurutkan berdasarkan ts_rank — produk yang paling relevan dengan kata kunci muncul di atas, bukan sekadar urutan waktu dibuat.
07.15 Out-of-Stock Product Behavior
AC4 punya aturan spesifik: produk habis tetap ditampilkan. Catatan berikut merangkum bagaimana repository dan handler memperlakukan produk out-of-stock.
1// AC4: "Produk habis stok masih di-return, tidak di-hide"
2
3// Repository: tidak ada filter is_active = false atau stock > 0 untuk GetByID
4// Query:
5// SELECT * FROM products WHERE id = $1
6// (tanpa WHERE stock > 0)
7
8// Handler response:
9// stock: p.AvailableStock() -- bisa return 0
10// is_active: p.IsActive -- bisa return false
11
12// Frontend yang handle tampilan "Habis" atau "Tidak Tersedia"
13// Backend tidak menyembunyikan produk dari hasil pencarian
14
15// Catatan: ListProducts HANYA return is_active = true
16// GetByID: return produk apapun termasuk inactive dan out-of-stockBedakan dua endpoint: ListProducts tetap menyaring is_active = true (katalog bersih), sementara GetByID mengembalikan produk apa pun — cocok untuk halaman detail yang di-bookmark customer meski stok sudah habis.
07.16 Product Images
UI butuh gambar produk meski belum masuk spec. Struktur berikut menunjukkan ekstensi domain untuk mendukung images tanpa merusak kontrak spec.
1// Domain entity mendukung images (belum di-spec, tapi perlu untuk UI)
2
3type Product struct {
4 // ... existing fields
5 Images []ProductImage
6}
7
8type ProductImage struct {
9 ID uuid.UUID
10 ProductID uuid.UUID
11 URL string
12 IsPrimary bool
13 SortOrder int
14}
15
16// Repository extension (di luar spec):
17func (r *ProductRepository) GetImagesForProduct(ctx context.Context, productID string) ([]domainProduct.ProductImage, error) {
18 rows, err := r.db.Query(ctx,
19 `SELECT id, product_id, url, is_primary, sort_order
20 FROM product_images WHERE product_id = $1 ORDER BY sort_order`,
21 productID)
22 // ... scan rows
23}Menandai fitur ini “di luar spec” penting untuk disiplin SDD — kita tetap boleh menambahkan kebutuhan nyata UI, asalkan jelas bahwa itu bukan bagian dari AC yang di-audit.
07.17 Performance: Query Optimization
Query katalog harus cepat di skala besar. Skrip berikut mendefinisikan index kritis dan menunjukkan cara memverifikasi rencana eksekusinya.
1-- Indexes yang penting untuk Product Service
2
3-- Untuk ListProducts dengan filter
4CREATE INDEX idx_products_category_active
5 ON products(category_id, is_active)
6 WHERE is_active = true;
7
8-- Untuk price range filter
9CREATE INDEX idx_products_price
10 ON products(price_idr)
11 WHERE is_active = true;
12
13-- Untuk full-text search (sudah ada dari Artikel 02)
14CREATE INDEX idx_products_search_vector
15 ON products USING GIN(search_vector);
16
17-- Query analysis:
18EXPLAIN ANALYZE
19SELECT * FROM products
20WHERE search_vector @@ plainto_tsquery('indonesian', 'sepatu lari')
21ORDER BY ts_rank(search_vector, plainto_tsquery('indonesian', 'sepatu lari')) DESC
22LIMIT 10;
23-- Expected: "Bitmap Index Scan on idx_products_search_vector"Melihat “Bitmap Index Scan” di output EXPLAIN ANALYZE adalah konfirmasi bahwa GIN index dipakai — jika muncul “Seq Scan”, berarti index tidak terpakai dan search akan lambat di produksi.
07.18 Atomic Stock Operation Pattern
Race condition adalah musuh utama inventory. Diagram berikut membandingkan skenario tanpa dan dengan optimistic locking saat dua user checkout produk terakhir.
1RACE CONDITION SCENARIO:
2
3User A dan User B checkout produk yang stoknya 1 secara bersamaan:
4
5TANPA optimistic locking:
6 A: SELECT stock = 1 → OK
7 B: SELECT stock = 1 → OK
8 A: UPDATE stock = 0 → OK (1 sold)
9 B: UPDATE stock = 0 → OK (oversold! -1 stok)
10
11DENGAN optimistic locking (ReserveStock):
12 A: UPDATE SET reserved = reserved + 1
13 WHERE (stock - reserved) >= 1
14 → 1 row affected → SUCCESS
15 B: UPDATE SET reserved = reserved + 1
16 WHERE (stock - reserved) >= 1
17 → stock=1, reserved=1, available=0
18 → 0 rows affected → ErrInsufficientStock
19
20Single SQL update = atomic di PostgreSQL.
21Tidak perlu SELECT dulu — tidak ada race window.Kesimpulan diagram ini jelas: menggabungkan cek dan update dalam satu statement UPDATE menghapus race window sepenuhnya — tidak perlu lock eksplisit maupun transaksi tambahan.
07.19 Soldout Handling
Bagaimana menampilkan stok yang benar ke customer? Method entity berikut menghitung stok tersedia dan memastikan handler tidak pernah membocorkan angka reserved.
1// Produk sold out: stock - reserved_stock = 0
2// AvailableStock() method pada entity:
3
4func (p *Product) AvailableStock() int {
5 available := p.Stock - p.ReservedStock
6 if available < 0 { return 0 } // safety
7 return available
8}
9
10// Dalam response: tampilkan available_stock, bukan raw stock
11// Handler:
12return c.JSON(200, map[string]interface{}{
13 "id": p.ID,
14 "name": p.Name,
15 "price_idr": p.PriceIDR,
16 "stock": p.AvailableStock(), // ← available, not total
17 "is_active": p.IsActive,
18})Menaruh AvailableStock() sebagai method entity (bukan di query SQL atau handler) menjaga aturan bisnis tetap di domain layer — mudah diuji tanpa database dan konsisten di mana pun dipakai.
07.20 Ringkasan: Product Service Selesai
Product Service sekarang fully functional. Empat pilar berikut merangkum apa yang sudah dibangun.
ListProducts: dynamic SQL dengan full-text search tsvector, filter category/price, pagination, sorted by relevance jika ada query.
GetProduct: single product by ID, return produk aktif maupun inactive (stock 0 tetap di-return sesuai AC4).
ReserveStock: optimistic locking via single UPDATE — no SELECT needed, race-free, atomic.
pgvector column: embedding VECTOR(1536) sudah ada, kosong untuk sekarang, akan diisi di Topik 6.
Specify audit score setelah Artikel 07 naik ke ~50/100 (dari 35/100). Fondasi katalog beres — berikutnya kita masuk ke jantung transaksi: Order Service.