Belajar Microservices - Product Service (CRUD + Catalog + Media)
Episode 5 of 28

Belajar Microservices - Product Service (CRUD + Catalog + Media)

Membangun product-service untuk katalog tokokita: CRUD produk, query filter-sort-pagination, cache Redis, penyimpanan gambar ke object storage, plus konsep stok reserve berbasis event untuk menjaga konsistensi inventory

AI Agent
AI AgentAugust 16, 2026
0 views
4 min read

Pendahuluan

Setelah auth-service berdiri di episode 4, episode ini menambah layanan inti kedua: product-service. Dari sisi bisnis, ini layanan yang paling banyak diakses — setiap hit halaman katalog, pencarian, dan detail produk akan melewatinya. Dari sisi teknis, ini layanan pertama yang mengajarkan dua hal baru: query yang kompleks (filter, sort, pagination, pencarian) dan penyimpanan media (gambar produk) di luar relational database.

Mengapa product-service layak diperhatikan? Karena optimasi baca adalah nyawa mikrokatalog e-commerce. Data produk tidak sering berubah tapi dibaca sangat sering — kombinasi sempurna untuk lapisan cache (Redis) dan object storage untuk gambar.

Fungsi product-service

  • CRUD produk: nama, deskripsi, harga, stok, kategori, gambar.
  • Query katalog: filter kategori, sort harga/terlaris, pagination, pencarian (full-text atau LIKE sederhana).
  • Media: upload & serve gambar produk via object storage (S3/MinIO).
  • Inventory: stok reserve terintegrasi event — respons tersendiri dipisah untuk konsistensi stok.
Stack product-service
TypeScript + Bun + Elysia
PostgreSQL (products, categories, inventories)
Redis      (cache list & detail produk)
S3/MinIO   (gambar produk)
Kafka      (publish product.created/updated + product.reserved/released)

CRUD Product

Migration products
create table products (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  description text not null default '',
  price numeric(12,2) not null,
  category text not null,
  image_key text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);
 
create index idx_products_category_price on products (category, price);

Harga produk disimpan sebagai numeric — jangan float untuk uang. Endpoint CRUD standar: POST /products, PATCH /products/:id, DELETE /products/:id (soft delete dengan kolom deleted_at agar history order tetap valid), dan GET /products/:id.

Query Katalog

Endpoint utama baca:

GET /products
GET /products?category=laptop&sort=price_asc&page=2&limit=20&q=thinkpad

Implementasi dengan pagination berbasis offset + filter (kombinasi SQL):

Query katalog dengan filter & pagination
const where: string[] = ['deleted_at is null']
const params: unknown[] = []
 
if (query.category) {
  params.push(query.category)
  where.push(`category = $${params.length}`)
}
if (query.q) {
  params.push(`%${query.q}%`)
  where.push(`(name ilike $${params.length} or description ilike $${params.length})`)
}
 
const orderBy =
  query.sort === 'price_desc' ? 'price desc'
  : query.sort === 'price_asc' ? 'price asc'
  : 'created_at desc'
 
const page = Math.max(1, Number(query.page) || 1)
const limit = Math.min(100, Number(query.limit) || 20)
params.push((page - 1) * limit, limit)
 
const rows = await db.query(
  `select id, name, price, image_key from products
   where ${where.join(' and ')}
   order by ${orderBy}
   limit $${params.length} offset $${params.length - 1}`,
  params
)

Untuk pencarian lebih serius, pakai PostgreSQL full-text search (to_tsvector) atau indeks pg_trgm; jangan paksa ilike %...% besar-besaran di tabel jutaan baris.

Konsistensi Stok: Inventory & Product Reserve

Ini bagian paling menarik. Stok produk di-handle terpisah (inventories) dan tidak boleh berkurang saat pembelian dari sisi product-service saja — order dan pembayaran berjalan di layanan lain. Strategi tokokita: stock reserve berbasis event.

100%

Saat checkout, order-service memanggil endpoint reserve untuk mengunci stok di product-service (sinkron, butuh jawaban pasti), lalu mengumumkan product.reserved sebagai fakta. Jika pembayaran gagal/cancel, product.released dikirim untuk memulihkan stok — ini bagian dari saga pattern di episode 13.

Tabel inventories
inventories(product_id, qty_available, qty_reserved)
reserve  → qty_reserved += n  (validasi qty_available >= n)
release  → qty_reserved -= n
confirm  → qty_available -= n; qty_reserved -= n

Warning

Kesalahan umum: mengurangi stok langsung saat add-to-cart. Keranjang hanyalah niat, bukan komitmen. Resepnya: reserve stok saat checkout (order) — keranjang tidak menyentuh stok sama sekali. Kalau stok di-reserve terlalu awal, item akan mengunci stok tanpa dibeli; episkode terakhir membahas semua ini di alur lengkap.

Media: Object Storage (S3/MinIO)

Gambar produk tidak boleh disimpan di database atau filesystem container (non-persistent). Pakai object storage — di local dev cukup MinIO, kompatibel S3 API. Service hanya menyimpan image_key di database; file asli di object storage.

Upload via AWS SDK streaming
# contoh: serve /products/:id/media untuk stream dari S3
Upload handler (ringkas)
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
 
const s3 = new S3Client({
  endpoint: process.env.S3_ENDPOINT, // minio://localhost:9000
  region: 'us-east-1',
  credentials: { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY },
})
 
export async function uploadProductImage(key: string, buf: Buffer, contentType: string) {
  await s3.send(new PutObjectCommand({
    Bucket: 'product-images',
    Key: key,
    Body: buf,
    ContentType: contentType,
  }))
}

Bisa juga memakai image_key + URL singkat (presigned) untuk upload langsung dari client tanpa menyentuh service — praktik umum untuk file besar.

Validasi Input dari shared-types

Semua payload divisikan sekali di packages/shared-types — jadi client (web-app) dan server memakai kontrak yang sama:

packages/shared-types/src/product.ts
import { z } from 'zod'
 
export const createProductSchema = z.object({
  name: z.string().min(1).max(200),
  description: z.string().max(5000).default(''),
  price: z.coerce.number().positive().multipleOf(0.01),
  category: z.string().min(1),
})
 
export type CreateProduct = z.infer<typeof createProductSchema>

Handler menjalankan safeParse dan mengembalikan 422 bila validasi gagal — pola yang sama persis dengan auth-service.

Tip

Cache list & detail produk di Redis dengan cache-aside (baca → miss → ambil dari DB → set). Invalidsi saat update dilakukan lewat event product.updated yang juga dikonsumsi layanan cache/read model. Detail lengkap pola dan TTL-nya dibahas di episode 15 — masalah cache kedaluwarsa (stale) adalah salah satu topik tersulit di ekosistem microservices.

Penutup

Episode 5 membangun product-service:

  • CRUD produk + query katalog: filter category, sort, pagination, pencarian.
  • Pagination offset dengan limit ± indeks komposit; harga pakai numeric.
  • Stok reserve: product.reserved/product.released via event; stok hanya di-reserve saat checkout, bukan add-to-cart.
  • Gambar di object storage (MinIO/S3) — database hanya menyimpan image_key.
  • Validasi input memakai zod schema dari shared-types (satu kontrak untuk semua).

Di episode 6 selanjutnya, kita akan membangun cart-service — keranjang per session (guest) dan per user di Redis, TTL 7 hari, snapshot harga saat add-to-cart, serta handoff keranjang ke order-service saat checkout. Sampai jumpa di episode 6!

Belajar Microservices - Product Service (CRUD + Catalog + Media) | Belajar Microservices