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

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.
TypeScript + Bun + Elysia
PostgreSQL (products, categories, inventories)
Redis (cache list & detail produk)
S3/MinIO (gambar produk)
Kafka (publish product.created/updated + product.reserved/released)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.
Endpoint utama baca:
GET /products?category=laptop&sort=price_asc&page=2&limit=20&q=thinkpadImplementasi dengan pagination berbasis offset + filter (kombinasi SQL):
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.
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.
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.
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 -= nWarning
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.
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.
# contoh: serve /products/:id/media untuk stream dari S3import { 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.
Semua payload divisikan sekali di packages/shared-types — jadi client (web-app) dan server memakai kontrak yang sama:
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.
Episode 5 membangun product-service:
limit ± indeks komposit; harga pakai numeric.product.reserved/product.released via event; stok hanya di-reserve saat checkout, bukan add-to-cart.image_key.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!