Tampilan
Kenapa penting
Selama M01–M03 kamu belajar konsep satu per satu. Pekerjaan backend sehari-hari justru menggabungkannya: membaca kebutuhan, memodelkan data, memilih struktur data, menjaga aturan bisnis, dan memutuskan apa yang terjadi saat ada yang salah. Bab ini adalah latihan lengkap pertama: layanan inventaris kecil yang datanya disimpan di memori. Bentuk tugas seperti ini juga sering muncul sebagai take-home test saat melamar posisi backend junior — penilai melihat kerapian desain, penanganan error, dan test, bukan sekadar "jalan".
Konsep inti
1. Kebutuhan
Sebagai admin gudang, saya ingin mencatat produk dan stoknya, mengurangi stok saat ada pesanan, dan melihat produk yang stoknya menipis, agar toko tidak menjual barang yang tidak ada.
Aturan bisnis yang harus dijaga:
| ID | Aturan |
|---|---|
| R1 | Setiap produk punya SKU unik; SKU tidak peka huruf besar/kecil dan spasi tepi (kopi-001 = KOPI-001) |
| R2 | Nama wajib diisi; harga dan stok tidak boleh negatif |
| R3 | Restock dan pemesanan hanya untuk jumlah > 0 |
| R4 | Pemesanan yang melebihi stok ditolak dan stok tidak berubah |
| R5 | Pesanan berisi banyak item diproses semua atau tidak sama sekali |
| R6 | Laporan stok menipis urut dari stok terkecil; nilai inventaris = Σ harga × stok |
Menuliskan aturan seperti ini sebelum coding memudahkan dua hal: kamu tahu persis apa yang harus diuji, dan diskusi dengan tim produk punya bahasa yang sama ("R4 dilanggar di kasus ini").
2. Desain: tiga bagian kecil
text
pemanggil (sekarang: test & main — nanti: controller REST)
│ add · find · restock · reserve · lowStock · totalValue
▼
InventoryService ──► Map<String, Product> ("database" in-memory, kunci = SKU)
│ melempar
▼
ProductNotFoundException · DuplicateSkuException · InsufficientStockException| Bagian | Jenis | Tanggung jawab |
|---|---|---|
Product | record immutable | data satu produk + validasi R1–R2 di compact constructor |
InventoryService | class final | aturan R3–R6, satu-satunya pemilik Map produk |
| Tiga exception domain | extends RuntimeException | menjelaskan kenapa operasi gagal, membawa data pendukung |
Pemanggil tidak pernah menyentuh Map secara langsung — semuanya lewat method service. Inilah encapsulation dari M02 dalam skala yang lebih besar.
3. Keputusan desain dan alasannya
Record immutable, "ganti object" saat stok berubah. Product tidak punya setter. Mengubah stok berarti membuat object baru lewat withStock(...) lalu menyimpannya kembali ke map dengan kunci yang sama. Keuntungannya: object Product yang sudah dikembalikan ke pemanggil tidak bisa berubah diam-diam, dan validasi R2 otomatis berlaku lagi setiap kali object baru dibuat — stok negatif mustahil tersimpan.
Normalisasi SKU di satu tempat. Compact constructor memanggil Product.normalizeSku(...), dan service memakai method yang sama sebelum mencari. Kalau normalisasi ditulis ulang di beberapa tempat, cepat atau lambat ada yang berbeda dan muncul bug klasik "produknya ada tapi tidak ketemu".
LinkedHashMap sebagai penyimpanan. Pencarian per SKU harus cepat (get pada map berbasis hash), dan urutan penyisipan yang stabil membuat output mudah ditebak saat debugging dan testing. HashMap tidak menjamin urutan; TreeMap mengurutkan kunci, tetapi laporan kita butuh urutan lain (stok), jadi kita mengurutkan sendiri saat perlu.
Exception domain unchecked. Pemanggil langsung (nanti controller) hampir tidak pernah bisa "memperbaiki" stok yang kurang — yang bisa dilakukan hanyalah melaporkannya ke klien. Exception checked akan memaksa setiap lapisan menulis throws atau try/catch tanpa nilai tambah. Dengan unchecked, exception mengalir ke satu tempat penanganan terpusat yang memetakannya ke respons HTTP. InsufficientStockException juga membawa requested() dan available() agar pesan error ke klien bisa spesifik.
Kembalikan salinan yang tidak bisa diubah. lowStock mengembalikan List.copyOf(...). Pemanggil bebas membaca, tetapi tidak bisa menambah atau menghapus isinya — dan tidak ada referensi ke struktur internal yang bocor keluar.
Validasi semua, baru ubah (R5). Object di memori tidak punya rollback. Untuk operasi banyak item, lakukan dua tahap: periksa semuanya tanpa mengubah apa pun, lalu terapkan perubahan setelah yakin semuanya lolos.
4. Langkah implementasi bertahap
Kerjakan InventoryService sedikit demi sedikit, dan jalankan test setelah setiap langkah. Dari folder exercises/:
bash
# semua test proyek mini
./mvnw -pl m03-collections-exception test -Dtest=InventoryServiceTest
# satu test saja, mis. saat fokus ke add() & find()
./mvnw -pl m03-collections-exception test -Dtest='InventoryServiceTest#addAndFind'adddanfind. Fieldproductssudah disiapkan.addmenolak SKU yang sudah ada;findmenormalisasi SKU lalu mencari, dan melemparProductNotFoundExceptionbila hasilnyanull. Ingat dari bab Map:getmengembalikannulluntuk kunci yang tidak ada.restockdanreserve. Validasiquantitydulu (R3), pakaifindagar SKU tak dikenal otomatis ditolak, lalu simpan object hasilwithStock(...). Pola penyimpanannya:java// Potongan: mengganti produk lama dengan versi barunya — kuncinya tetap SKU yang sama products.put(updated.sku(), updated);Untuk
reserve, periksa stok sebelum menyimpan (R4).lowStockdantotalValue. Kumpulkan produk keArrayList, urutkan denganComparatorgabungan (stok, lalu SKU — lihat bab Iterasi & Sorting), lalu kembalikanList.copyOf(...). UntuktotalValue, ingat jebakan overflow dari M01: hitung dalamlong.Tantangan.
reserveAllmenerapkan pola dua tahap. Perhatikan kasus pesanan yang menyebut SKU sama dua kali dengan penulisan berbeda — ada test khusus untuk itu.valueBySkuPrefixmenggabungkanindexOf/substringdari M01 denganTreeMapdangetOrDefaultdari bab Map.
Kalau test gagal, baca nama test dan pesan AssertJ-nya: keduanya ditulis untuk menjelaskan aturan mana yang belum terpenuhi. Hindari mengubah test — test adalah spesifikasi.
5. Dari proyek mini ke Spring Boot
Struktur ini sengaja dibuat mirip aplikasi backend sungguhan:
| Proyek mini | Aplikasi Spring Boot nanti |
|---|---|
Map<String, Product> | repository + database (JPA), dengan transaksi untuk R5 |
InventoryService | class @Service berisi aturan bisnis |
test JUnit dan main | controller REST yang menerima request JSON |
| exception domain | @ExceptionHandler: not found → 404, duplikat & stok kurang → 409 |
IllegalArgumentException input | validasi request → 400 Bad Request |
Satu hal yang belum ditangani: LinkedHashMap tidak aman dipakai banyak thread sekaligus. Server web melayani banyak request bersamaan, jadi dua pesanan bisa membaca stok yang sama lalu sama-sama lolos. Di aplikasi nyata, masalah ini diselesaikan database (transaksi dan locking) — topik level berikutnya.
Contoh di dunia backend
Pola yang sama dipakai untuk layanan voucher diskon: record immutable dengan validasi, service yang memiliki Map, exception domain, operasi atomik, dan "controller" kecil yang memetakan exception ke status HTTP.
java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
record Voucher(String code, int discountPercent, int quota) {
Voucher {
if (code == null || code.isBlank()) {
throw new IllegalArgumentException("Kode voucher wajib diisi");
}
if (discountPercent < 1 || discountPercent > 100 || quota < 0) {
throw new IllegalArgumentException("Voucher tidak valid: " + code);
}
code = code.strip().toUpperCase();
}
Voucher withQuota(int newQuota) {
return new Voucher(code, discountPercent, newQuota);
}
}
class VoucherNotFoundException extends RuntimeException {
VoucherNotFoundException(String code) {
super("Voucher tidak ditemukan: " + code);
}
}
class VoucherExhaustedException extends RuntimeException {
VoucherExhaustedException(String code) {
super("Kuota voucher habis: " + code);
}
}
class VoucherService {
private final Map<String, Voucher> vouchers = new LinkedHashMap<>();
void register(Voucher voucher) {
vouchers.put(voucher.code(), voucher);
}
Voucher get(String code) {
Voucher voucher = vouchers.get(code.strip().toUpperCase());
if (voucher == null) {
throw new VoucherNotFoundException(code);
}
return voucher;
}
/** Validasi semua kode dulu, baru kurangi kuota — semua berhasil atau tidak sama sekali. */
void redeemAll(String... codes) {
for (String code : codes) {
if (get(code).quota() == 0) {
throw new VoucherExhaustedException(get(code).code());
}
}
for (String code : codes) {
Voucher voucher = get(code);
vouchers.put(voucher.code(), voucher.withQuota(voucher.quota() - 1));
}
}
List<Voucher> available() {
List<Voucher> result = new ArrayList<>();
for (Voucher voucher : vouchers.values()) {
if (voucher.quota() > 0) {
result.add(voucher);
}
}
result.sort(Comparator.comparing(Voucher::discountPercent).reversed());
return List.copyOf(result);
}
}
public class VoucherDemo {
// Meniru apa yang nanti dikerjakan controller REST: panggil service, petakan exception ke status HTTP.
static String checkout(VoucherService service, String... codes) {
try {
service.redeemAll(codes);
return "200 OK";
} catch (VoucherNotFoundException e) {
return "404 Not Found — " + e.getMessage();
} catch (VoucherExhaustedException e) {
return "409 Conflict — " + e.getMessage();
}
}
public static void main(String[] args) {
VoucherService service = new VoucherService();
service.register(new Voucher("hemat10", 10, 5));
service.register(new Voucher("ONGKIR", 100, 1));
service.register(new Voucher("FLASH50", 50, 0));
System.out.println(checkout(service, "hemat10", " ongkir "));
System.out.println(checkout(service, "HEMAT10", "ONGKIR"));
System.out.println(checkout(service, "HEMAT10", "GRATIS"));
System.out.println("Sisa HEMAT10: " + service.get("HEMAT10").quota());
System.out.println("Tersedia: " + service.available());
}
}Output:
text
200 OK
409 Conflict — Kuota voucher habis: ONGKIR
404 Not Found — Voucher tidak ditemukan: GRATIS
Sisa HEMAT10: 4
Tersedia: [Voucher[code=HEMAT10, discountPercent=10, quota=4]]Perhatikan baris "Sisa HEMAT10: 4". Checkout pertama memakai satu kuota (5 → 4). Checkout kedua dan ketiga gagal di kode kedua — dan karena validasi dilakukan sebelum perubahan apa pun, kuota HEMAT10 tidak ikut terpotong. Tanpa pola dua tahap, pelanggan akan kehilangan kuota voucher untuk pesanan yang tidak pernah terjadi. Method checkout adalah gambaran kecil dari penanganan error terpusat di Spring Boot: service hanya melempar exception yang bermakna, dan satu tempat lain yang memutuskan bentuk respons HTTP-nya.
Pertanyaan interview
1. Bagaimana kamu memastikan operasi yang mengubah banyak data bersifat atomik?
Untuk data di memori: validasi semua bagian lebih dulu tanpa mengubah apa pun, lalu terapkan perubahan setelah semuanya lolos. Untuk data di database: bungkus operasi dalam satu transaksi (di Spring, @Transactional), sehingga kegagalan di tengah jalan membatalkan semua perubahan (rollback).
2. Exception domain sebaiknya checked atau unchecked, dan bagaimana sampai ke klien?
Umumnya unchecked: pemanggil langsung jarang bisa memulihkannya, dan checked exception memaksa throws/try-catch di setiap lapisan tanpa nilai tambah. Exception dibiarkan naik ke penanganan terpusat (di Spring: @ExceptionHandler / @RestControllerAdvice) yang memetakannya ke status HTTP — mis. not found → 404, konflik stok → 409 — dengan pesan dan data pendukung yang jelas.
3. Apa risiko menyimpan data di HashMap di dalam service yang dipakai banyak request?
HashMap/LinkedHashMap tidak thread-safe: dua request bisa membaca stok yang sama dan sama-sama lolos (overselling), atau struktur map bisa rusak. Data juga hilang saat aplikasi restart dan tidak terbagi antar-instance server. Solusinya database dengan transaksi/locking; untuk cache di memori, struktur concurrent seperti ConcurrentHashMap dengan operasi atomik.
Jebakan umum
- Mengembalikan map atau list internal. Pemanggil bisa mengubah state service tanpa lewat aturan bisnis. Kembalikan salinan yang tidak bisa diubah (
List.copyOf,Map.copyOf). - Mengubah state sebelum semua validasi selesai. Exception di tengah loop meninggalkan data setengah jadi.
- Normalisasi kunci di banyak tempat. Satu tempat lupa
toUpperCase()→ "produk tidak ditemukan" padahal ada. catchkosong. Menelan exception membuat pesanan "berhasil" padahal stok tidak berubah. Tangani dengan jelas atau biarkan exception naik.intuntuk nilai total. Harga × stok mudah melewati 2,1 miliar; pakailong.- Mengandalkan urutan
HashMap. Bila urutan penting, pilihLinkedHashMapatauTreeMap, atau urutkan eksplisit.
Ringkasan
- Tulis kebutuhan dan aturan bisnis dulu; aturan itulah yang diuji.
- Pisahkan model (record immutable dengan validasi), service (pemilik data & aturan), dan exception domain.
LinkedHashMapcocok untuk penyimpanan in-memory: cepat per kunci dan urutannya stabil.- Exception domain unchecked yang membawa data pendukung mudah dipetakan ke status HTTP.
- Kembalikan salinan yang tidak bisa diubah, dan buat operasi banyak item atomik dengan pola dua tahap.
- Pola ini langsung terbawa ke Spring Boot: repository,
@Service, controller, dan@ExceptionHandler.