Tampilan
Kenapa penting
Tugas backend pertama di banyak tim berbentuk persis seperti ini: "buatkan endpoint untuk mengelola produk — tambah, ubah, hapus, cari, dengan error yang rapi dan ada test-nya." Proyek mini ini merangkai semua bab M07 — DI, desain REST, controller, DTO + validasi, ProblemDetail, dan @WebMvcTest — menjadi satu aplikasi utuh. Datanya disimpan di memori dulu agar kamu fokus pada lapisan web; di M08 repository-nya akan diganti PostgreSQL tanpa mengubah controller.
Konsep inti
Kebutuhan & tabel endpoint
Katalog menyimpan produk dengan field sku (unik), name, category, price, dan stock. Kebutuhannya:
- CRUD produk; SKU tidak boleh dipakai dua produk.
- Pencarian berdasarkan kata kunci di nama (
q) dan kategori (category), hasilnya berhalaman. - Semua error memakai format
ProblemDetail(application/problem+json).
| Method | Path | Sukses | Error |
|---|---|---|---|
GET | /api/products | 200 + halaman hasil | — |
GET | /api/products/{id} | 200 + produk | 404 |
POST | /api/products | 201 + Location + produk | 400, 409 SKU ganda |
PUT | /api/products/{id} | 200 + produk | 400, 404, 409 |
DELETE | /api/products/{id} | 204 tanpa body | 404 |
Query param pencarian: q, category, page (mulai 0, default 0), size (default 20, maksimal 50).
Setup & struktur package
Buat project di Spring Initializr (Maven, Java 25, Spring Boot 4.1.x) dengan dependency Spring Web dan Validation. Di Boot 4 keduanya menjadi starter berikut, lengkap dengan pasangan starter test-nya:
xml
<!-- Potongan pom.xml (parent: spring-boot-starter-parent 4.1.x) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>Susun package berdasarkan lapisan. Arah dependensi satu jalur: controller → service → repository; dto dan exception dipakai lintas lapisan.
text
src/main/java/id/jalurjava/catalog/
├── CatalogApplication.java
├── controller/ProductController.java
├── service/ProductService.java
├── repository/ProductRepository.java (interface)
├── repository/InMemoryProductRepository.java (implementasi in-memory)
├── domain/Product.java
├── dto/ProductRequest.java · ProductResponse.java · PageResponse.java
└── exception/ProductNotFoundException.java · DuplicateSkuException.java · GlobalExceptionHandler.java
src/test/java/id/jalurjava/catalog/
├── controller/ProductControllerTest.java
└── service/ProductServiceTest.javaPackage per lapisan vs per fitur
Untuk satu resource, package per lapisan paling mudah dibaca. Saat fitur bertambah (pesanan, pelanggan), banyak tim beralih ke package per fitur (product/, order/) agar perubahan satu fitur tidak tersebar ke lima folder.
Domain & repository in-memory
Product adalah record domain. Repository disembunyikan di balik interface agar service tidak tahu datanya tinggal di memori atau database.
java
// domain/Product.java
package id.jalurjava.catalog.domain;
import java.math.BigDecimal;
public record Product(Long id, String sku, String name, String category, BigDecimal price, int stock) {
public Product withId(Long newId) {
return new Product(newId, sku, name, category, price, stock);
}
}java
// repository/InMemoryProductRepository.java
package id.jalurjava.catalog.repository;
import id.jalurjava.catalog.domain.Product;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Repository;
@Repository
public class InMemoryProductRepository implements ProductRepository {
private final Map<Long, Product> store = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
@Override
public Product save(Product product) {
Product toSave = product.id() == null ? product.withId(sequence.incrementAndGet()) : product;
store.put(toSave.id(), toSave);
return toSave;
}
@Override
public Optional<Product> findById(long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Optional<Product> findBySku(String sku) {
return store.values().stream().filter(p -> p.sku().equalsIgnoreCase(sku)).findFirst();
}
@Override
public List<Product> findAll() {
return store.values().stream().sorted(Comparator.comparing(Product::id)).toList();
}
@Override
public boolean deleteById(long id) {
return store.remove(id) != null;
}
}Interface ProductRepository cukup berisi kelima signature method di atas. Bean ini singleton dan dipanggil banyak thread request sekaligus, karena itu pakai ConcurrentHashMap dan AtomicLong, bukan HashMap dan long biasa.
DTO + validasi
Request dan response dipisah dari domain. ProductRequest memegang aturan validasi input; ProductResponse menentukan apa yang boleh dilihat client.
java
// dto/ProductRequest.java
package id.jalurjava.catalog.dto;
import id.jalurjava.catalog.domain.Product;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.Locale;
public record ProductRequest(
@NotBlank @Size(max = 32)
@Pattern(regexp = "[A-Z0-9-]+", message = "hanya huruf besar, angka, dan tanda minus")
String sku,
@NotBlank @Size(max = 100) String name,
@NotBlank String category,
@NotNull @Positive BigDecimal price,
@NotNull @PositiveOrZero Integer stock) {
public Product toProduct(Long id) {
return new Product(id, sku, name.strip(), category.strip().toLowerCase(Locale.ROOT), price, stock);
}
}java
// dto/ProductResponse.java & dto/PageResponse.java (dua file terpisah)
public record ProductResponse(long id, String sku, String name, String category, BigDecimal price, int stock) {
public static ProductResponse from(Product p) {
return new ProductResponse(p.id(), p.sku(), p.name(), p.category(), p.price(), p.stock());
}
}
public record PageResponse<T>(List<T> content, int page, int size, long totalElements, int totalPages) {}Perhatikan stock bertipe Integer + @NotNull, bukan int: field yang lupa dikirim client ditolak lewat validasi dengan pesan yang jelas, bukan diam-diam dianggap nol.
Service: aturan bisnis & pencarian
java
// service/ProductService.java
package id.jalurjava.catalog.service;
// import domain, dto, exception, repository, Locale, List ...
import org.springframework.stereotype.Service;
@Service
public class ProductService {
static final int MAX_PAGE_SIZE = 50;
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
public ProductResponse findById(long id) {
return repository.findById(id)
.map(ProductResponse::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
// synchronized: cek SKU + simpan harus atomik (lihat Jebakan umum)
public synchronized ProductResponse create(ProductRequest request) {
if (repository.findBySku(request.sku()).isPresent()) {
throw new DuplicateSkuException(request.sku());
}
return ProductResponse.from(repository.save(request.toProduct(null)));
}
public synchronized ProductResponse update(long id, ProductRequest request) {
if (repository.findById(id).isEmpty()) {
throw new ProductNotFoundException(id);
}
repository.findBySku(request.sku())
.filter(other -> other.id() != id) // SKU milik produk lain?
.ifPresent(other -> { throw new DuplicateSkuException(request.sku()); });
return ProductResponse.from(repository.save(request.toProduct(id)));
}
public void delete(long id) {
if (!repository.deleteById(id)) {
throw new ProductNotFoundException(id);
}
}
public PageResponse<ProductResponse> search(String q, String category, int page, int size) {
int safePage = Math.max(page, 0);
int safeSize = Math.clamp(size, 1, MAX_PAGE_SIZE);
String keyword = q == null ? "" : q.strip().toLowerCase(Locale.ROOT);
List<Product> matches = repository.findAll().stream()
.filter(p -> keyword.isEmpty() || p.name().toLowerCase(Locale.ROOT).contains(keyword))
.filter(p -> category == null || category.isBlank()
|| p.category().equalsIgnoreCase(category.strip()))
.toList();
List<ProductResponse> content = matches.stream()
.skip((long) safePage * safeSize)
.limit(safeSize)
.map(ProductResponse::from)
.toList();
int totalPages = (int) Math.ceil((double) matches.size() / safeSize);
return new PageResponse<>(content, safePage, safeSize, matches.size(), totalPages);
}
}Keputusan pagination di sini: nilai di luar batas dijepit, bukan ditolak, dan respons memantulkan nilai yang dipakai. Batas size wajib ada — tanpa itu satu request size=1000000 bisa menyedot memori server. Kedua exception adalah RuntimeException sederhana dengan pesan seperti "Produk dengan id 99 tidak ditemukan".
Controller
java
// controller/ProductController.java
package id.jalurjava.catalog.controller;
// import dto, service, jakarta.validation.Valid, java.net.URI ...
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping
public PageResponse<ProductResponse> search(
@RequestParam(required = false) String q,
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return service.search(q, category, page, size);
}
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable long id) {
return service.findById(id);
}
@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody ProductRequest request) {
ProductResponse created = service.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ProductResponse update(@PathVariable long id, @Valid @RequestBody ProductRequest request) {
return service.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable long id) {
service.delete(id);
}
}Controller tipis: menerjemahkan HTTP ke pemanggilan service, tanpa if bisnis dan tanpa try/catch.
ProblemDetail terpusat
Advice meng-extend ResponseEntityExceptionHandler agar error bawaan Spring MVC (JSON rusak, tipe path variable salah, method tidak didukung) juga keluar sebagai ProblemDetail. Error validasi diperkaya dengan peta errors.
java
// exception/GlobalExceptionHandler.java
package id.jalurjava.catalog.exception;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
ProblemDetail handleNotFound(ProductNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Produk tidak ditemukan");
return problem;
}
@ExceptionHandler(DuplicateSkuException.class)
ProblemDetail handleDuplicateSku(DuplicateSkuException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("SKU sudah dipakai");
return problem;
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> errors.putIfAbsent(error.getField(), error.getDefaultMessage()));
ProblemDetail problem = ex.getBody(); // sudah berstatus 400
problem.setTitle("Validasi gagal");
problem.setDetail("Ada field yang tidak valid.");
problem.setProperty("errors", errors);
return handleExceptionInternal(ex, problem, headers, status, request);
}
}Spring mengisi instance dengan path request bila kamu tidak mengisinya, jadi setiap error menunjuk URL yang memicunya.
Test: slice web + unit test service
Test controller memakai @WebMvcTest: hanya lapisan web yang dimuat (controller dan advice), service diganti mock. Yang diuji adalah kontrak HTTP: status, header, bentuk JSON.
java
// test: controller/ProductControllerTest.java
package id.jalurjava.catalog.controller;
import static org.hamcrest.Matchers.endsWith;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// import dto, exception, service, BigDecimal ...
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
ProductService service;
@Test
@DisplayName("POST valid → 201 dengan header Location")
void createReturns201() throws Exception {
given(service.create(any(ProductRequest.class))).willReturn(new ProductResponse(
1L, "KOPI-250", "Kopi Arabika 250 g", "minuman", new BigDecimal("85000"), 40));
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sku":"KOPI-250","name":"Kopi Arabika 250 g",
"category":"minuman","price":85000,"stock":40}
"""))
.andExpect(status().isCreated())
.andExpect(header().string("Location", endsWith("/api/products/1")))
.andExpect(jsonPath("$.sku").value("KOPI-250"));
}
@Test
@DisplayName("POST tidak valid → 400 ProblemDetail dengan peta errors, service tidak dipanggil")
void createRejectsInvalidBody() throws Exception {
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sku":"kopi 250","name":"","category":"minuman","price":-1}
"""))
.andExpect(status().isBadRequest())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.title").value("Validasi gagal"))
.andExpect(jsonPath("$.errors.sku").exists())
.andExpect(jsonPath("$.errors.name").exists())
.andExpect(jsonPath("$.errors.price").exists())
.andExpect(jsonPath("$.errors.stock").exists());
then(service).shouldHaveNoInteractions();
}
@Test
@DisplayName("GET id yang tidak ada → 404 ProblemDetail")
void findByIdReturns404() throws Exception {
given(service.findById(99L)).willThrow(new ProductNotFoundException(99L));
mockMvc.perform(get("/api/products/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.detail").value("Produk dengan id 99 tidak ditemukan"))
.andExpect(jsonPath("$.instance").value("/api/products/99"));
}
}Aturan bisnis (SKU ganda, pagination) diuji di service dengan repository in-memory asli — tanpa Spring, selesai dalam milidetik.
java
// test: service/ProductServiceTest.java (import AssertJ & class proyek dihilangkan)
class ProductServiceTest {
private final ProductService service = new ProductService(new InMemoryProductRepository());
private static ProductRequest request(String sku, String name) {
return new ProductRequest(sku, name, "minuman", new BigDecimal("10000"), 5);
}
@Test
@DisplayName("SKU yang sudah dipakai ditolak")
void rejectsDuplicateSku() {
service.create(request("KOPI-250", "Kopi Arabika"));
assertThatThrownBy(() -> service.create(request("KOPI-250", "Kopi Lain")))
.isInstanceOf(DuplicateSkuException.class);
}
@Test
@DisplayName("pencarian memfilter kata kunci lalu memotong per halaman")
void searchFiltersAndPaginates() {
for (int i = 1; i <= 5; i++) {
service.create(request("KOPI-" + i, "Kopi " + i));
}
service.create(request("TEH-1", "Teh Melati"));
PageResponse<ProductResponse> result = service.search("kopi", null, 1, 2);
assertThat(result.content()).extracting(ProductResponse::sku).containsExactly("KOPI-3", "KOPI-4");
assertThat(result.totalElements()).isEqualTo(5);
assertThat(result.totalPages()).isEqualTo(3);
}
@Test
@DisplayName("page negatif dan size kebesaran dijepit")
void clampsPaging() {
PageResponse<ProductResponse> result = service.search(null, null, -3, 1000);
assertThat(result.page()).isZero();
assertThat(result.size()).isEqualTo(ProductService.MAX_PAGE_SIZE);
}
}Contoh di dunia backend
Jalankan aplikasinya, lalu coba alur lengkap dari terminal:
bash
./mvnw spring-boot:run
# Tambah produk
curl -i -X POST localhost:8080/api/products \
-H 'Content-Type: application/json' \
-d '{"sku":"KOPI-250","name":"Kopi Arabika 250 g","category":"minuman","price":85000,"stock":40}'
# Cari dengan kata kunci + pagination
curl -s 'localhost:8080/api/products?q=kopi&page=0&size=10'
# Ubah, lalu hapus
curl -i -X PUT localhost:8080/api/products/1 \
-H 'Content-Type: application/json' \
-d '{"sku":"KOPI-250","name":"Kopi Arabika 250 g","category":"minuman","price":79000,"stock":35}'
curl -i -X DELETE localhost:8080/api/products/1
# Uji jalur error: body tidak valid (400)
curl -i -X POST localhost:8080/api/products \
-H 'Content-Type: application/json' -d '{"sku":"kopi 250","name":"","price":-1}'Respons POST pertama kurang lebih begini (dirapikan):
text
HTTP/1.1 201
Location: http://localhost:8080/api/products/1
Content-Type: application/json
{"id":1,"sku":"KOPI-250","name":"Kopi Arabika 250 g","category":"minuman","price":85000,"stock":40}Dan body untuk request yang tidak valid:
json
{
"type": "about:blank",
"title": "Validasi gagal",
"status": 400,
"detail": "Ada field yang tidak valid.",
"instance": "/api/products",
"errors": {
"sku": "hanya huruf besar, angka, dan tanda minus",
"name": "must not be blank",
"category": "must not be blank",
"price": "must be greater than 0",
"stock": "must not be null"
}
}Urutan properti JSON dan isi errors bisa berbeda; pesan bawaan Hibernate Validator mengikuti locale. Jangan membuat client bergantung pada urutan — client membaca berdasarkan nama field.
Sebelum menyebut proyek selesai, periksa dengan checklist ini:
| Area | Cek |
|---|---|
| Status code | 201 + Location untuk create, 204 untuk delete, 404/409 sesuai kasus |
| Validasi | Semua field wajib bertanda constraint; field yang hilang → 400, bukan nilai default |
| Error | Semua error application/problem+json; tidak ada stack trace di body |
| Pagination | size dibatasi; respons memuat page, size, totalElements, totalPages |
| Test | Jalur sukses dan gagal tiap endpoint; aturan bisnis diuji di service |
| Desain | Controller tanpa logika bisnis; domain tidak bocor ke respons |
Latihan menyusul
Latihan kode lengkap untuk proyek ini — starter, test JUnit yang harus kamu buat lulus, dan tantangan tambahan — akan tersedia di folder exercises/. Untuk sekarang, bangun sendiri dari nol mengikuti bab ini.
Pertanyaan interview
1. Kenapa request/response memakai DTO terpisah dari class domain?
DTO adalah kontrak API, domain adalah model bisnis. Dengan memisahkan keduanya, kamu bisa mengubah struktur internal tanpa memecah client, menyembunyikan field internal, dan meletakkan aturan validasi input di tempat yang tepat. Ini juga mencegah client mengisi field yang tidak boleh ia atur, seperti id.
2. Apa beda @WebMvcTest dan @SpringBootTest, dan kapan memakai masing-masing?
@WebMvcTest memuat lapisan web saja dan memakai mock untuk dependensi — cepat, cocok untuk menguji kontrak HTTP: mapping, validasi, status, dan format error. @SpringBootTest memuat seluruh context, dipakai untuk test integrasi yang melibatkan banyak lapisan atau infrastruktur nyata. Proporsi sehat: banyak test slice dan unit, sedikit test integrasi penuh.
3. Bagaimana kamu menjamin format error API konsisten?
Satu @RestControllerAdvice terpusat yang mengubah exception domain menjadi ProblemDetail (RFC 9457) dan meng-extend ResponseEntityExceptionHandler agar error bawaan framework juga berformat sama. Controller tidak menangkap exception sendiri. Format itu dikunci dengan test yang memeriksa content type dan field-nya.
Jebakan umum
- Lupa
@Validdi parameter@RequestBody. Constraint di record diam saja dan data kotor masuk ke service. - Cek-lalu-simpan tanpa sinkronisasi. Dua request SKU sama bisa lolos bersamaan.
synchronizedcukup untuk satu instance in-memory; dengan database, andalkan unique constraint (M08). - Pagination tanpa batas
size. Satu request bisa memuat seluruh katalog dan membebani memori server. - Mengembalikan record domain langsung dari controller. Field internal ikut bocor dan kontrak API terikat ke struktur domain.
try/catchdi setiap method controller. Format error jadi tidak seragam; serahkan ke advice terpusat.- Test hanya jalur sukses. Bug paling sering muncul di
400,404, dan409— uji jalur gagal juga.
Ringkasan
- Mulai dari tabel endpoint: method, path, status sukses, dan status error untuk tiap operasi.
- Lapisan
controller→service→repositorydengan DTO sebagai kontrak; repository di balik interface agar mudah diganti database. @Valid+ Bean Validation di DTO record;Integer+@NotNulluntuk angka wajib.- Satu
@RestControllerAdviceyang meng-extendResponseEntityExceptionHandlermenghasilkanProblemDetailuntuk semua error. @WebMvcTest+@MockitoBeanmenguji kontrak HTTP; unit test biasa menguji aturan bisnis di service.