Tampilan
Kenapa penting
Di bab sebelumnya kamu merancang URL, method, dan status code di atas kertas. Sekarang rancangan itu harus menjadi kode yang benar-benar menerima request HTTP. Di Spring Boot, pintu masuk itu adalah controller: class yang memetakan GET /api/products/42 ke method Java, mengubah JSON menjadi objek, lalu mengubah hasilnya kembali ke JSON dengan status yang tepat. Hampir semua tiket backend di tempat kerja menyentuh controller — menambah endpoint, query param baru, atau memperbaiki status code yang salah. Menulisnya dengan rapi (tipis, konsisten, status benar) adalah keterampilan harian.
Konsep inti
Dependency dan @RestController
Untuk aplikasi web servlet (Spring MVC + Tomcat embedded), Spring Boot 4 memakai starter spring-boot-starter-webmvc (nama lama spring-boot-starter-web masih ada tetapi sudah deprecated).
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>Controller REST adalah bean biasa yang ditandai @RestController. Anotasi ini adalah gabungan @Controller dan @ResponseBody: nilai kembalian method langsung ditulis ke body respons, dan Jackson mengubah objek (termasuk record) menjadi JSON.
java
// Potongan
package id.jalurjava.catalog;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HealthController {
@GetMapping("/api/ping")
public PingResponse ping() {
return new PingResponse("ok");
}
public record PingResponse(String status) {}
}GET /api/ping menghasilkan {"status":"ok"} dengan status 200 OK.
Mapping: @RequestMapping dan turunannya
@RequestMapping di level class memberi prefix untuk semua method. Di level method, pakai anotasi spesifik per HTTP method — lebih jelas dibanding @RequestMapping(method = ...).
| Anotasi | HTTP method | Pemakaian umum |
|---|---|---|
@GetMapping | GET | membaca satu/banyak resource |
@PostMapping | POST | membuat resource baru |
@PutMapping | PUT | mengganti seluruh resource |
@PatchMapping | PATCH | mengubah sebagian field |
@DeleteMapping | DELETE | menghapus resource |
java
// Potongan
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping // GET /api/products
public List<ProductResponse> list() { ... }
@GetMapping("/{id}") // GET /api/products/42
public ProductResponse get(@PathVariable long id) { ... }
@DeleteMapping("/{id}") // DELETE /api/products/42
public void delete(@PathVariable long id) { ... }
}Bila dua method memetakan kombinasi method + path yang sama, aplikasi gagal start dengan pesan ambiguous mapping — kesalahan ini cepat ketahuan, jadi jangan panik melihatnya.
Membaca input: @PathVariable, @RequestParam, @RequestBody
@PathVariablemengambil bagian URL (/{id}). Dipakai untuk identitas resource.@RequestParammengambil query string (?q=kopi&page=2). Dipakai untuk filter, pencarian, pagination.@RequestBodymengubah body JSON menjadi objek. Dipakai untuk data yang dibuat/diubah.
java
// Potongan
@GetMapping
public List<ProductResponse> search(
@RequestParam(required = false) String q, // tidak dikirim → null
@RequestParam(defaultValue = "0") int page, // tidak dikirim → 0
@RequestParam(defaultValue = "20") int size) {
return productService.search(q, page, size);
}
@PostMapping
public ProductResponse create(@RequestBody ProductRequest request) {
return productService.create(request);
}Secara default @RequestParam wajib: bila tidak dikirim, Spring menjawab 400 Bad Request tanpa memanggil method-mu. Ada dua cara membuatnya opsional: required = false (nilainya null, jadi pakai tipe referensi) atau defaultValue (otomatis tidak wajib). Konversi tipe juga otomatis — ?page=abc untuk parameter int ditolak dengan 400.
Nama parameter
@PathVariable long id tanpa nama eksplisit bekerja karena Spring membaca nama parameter dari bytecode. Ini butuh flag compiler -parameters, yang sudah disetel otomatis oleh parent Maven/plugin Gradle Spring Boot. Bila kamu mengompilasi di luar setup itu dan muncul error soal nama parameter, tulis eksplisit: @PathVariable("id").
ResponseEntity: mengatur status, header, dan body
Mengembalikan objek langsung selalu menghasilkan 200 OK. Saat status perlu berbeda — 201, 204, 404 — pakai ResponseEntity<T>.
java
// Potongan
@PostMapping
public ResponseEntity<ProductResponse> create(@RequestBody ProductRequest request) {
ProductResponse created = productService.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest() // http://host/api/products
.path("/{id}")
.buildAndExpand(created.id()) // .../api/products/7
.toUri();
return ResponseEntity.created(location).body(created); // 201 + header Location
}
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> get(@PathVariable long id) {
return productService.findById(id)
.map(ResponseEntity::ok) // 200 + body
.orElseGet(() -> ResponseEntity.notFound().build()); // 404 tanpa body
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable long id) {
return productService.delete(id)
? ResponseEntity.noContent().build() // 204
: ResponseEntity.notFound().build();
}ServletUriComponentsBuilder.fromCurrentRequest() membangun URL dari request yang sedang diproses, jadi header Location tetap benar di laptop, staging, maupun production tanpa hard-code host. Alternatif singkat untuk status tetap adalah @ResponseStatus(HttpStatus.CREATED) di method, tetapi itu tidak bisa menambah header.
Error terpusat menyusul
Mengembalikan 404 lewat Optional di setiap method cepat terasa berulang. Di bab error handling kamu akan melempar exception dari service dan memetakannya ke respons ProblemDetail di satu tempat dengan @RestControllerAdvice.
Lapisan controller → service: controller tipis
Controller hanya mengurus urusan HTTP: membaca path/query/body, memanggil service, memilih status dan header. Aturan bisnis (validasi stok, perhitungan harga, aturan duplikat) dan akses data tinggal di service. Keuntungannya: service bisa dipanggil dari tempat lain (scheduler, consumer pesan) dan dites dengan unit test biasa tanpa HTTP.
| Lapisan | Tanggung jawab | Tidak boleh |
|---|---|---|
| Controller | mapping URL, baca input, status code, header | aturan bisnis, akses penyimpanan |
| Service | aturan bisnis, orkestrasi, transaksi (nanti) | tahu soal ResponseEntity/HTTP |
| Repository | simpan/ambil data | aturan bisnis |
Contoh di dunia backend
Katalog produk toko online dengan penyimpanan in-memory. ConcurrentHashMap dan AtomicLong dipakai karena satu bean service dipanggil oleh banyak thread request sekaligus.
java
// Potongan — ProductService.java
package id.jalurjava.catalog;
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.Service;
@Service
public class ProductService {
private final Map<Long, ProductResponse> store = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
public List<ProductResponse> search(String q, int page, int size) {
return store.values().stream()
.filter(p -> q == null || p.name().toLowerCase().contains(q.toLowerCase()))
.sorted(Comparator.comparing(ProductResponse::id))
.skip((long) page * size)
.limit(size)
.toList();
}
public Optional<ProductResponse> findById(long id) {
return Optional.ofNullable(store.get(id));
}
public ProductResponse create(ProductRequest request) {
long id = sequence.incrementAndGet();
var product = new ProductResponse(id, request.name(), request.price(), request.stock());
store.put(id, product);
return product;
}
public Optional<ProductResponse> replace(long id, ProductRequest request) {
return Optional.ofNullable(store.computeIfPresent(id,
(key, old) -> new ProductResponse(key, request.name(), request.price(), request.stock())));
}
public Optional<ProductResponse> adjustStock(long id, int delta) {
return Optional.ofNullable(store.computeIfPresent(id,
(key, old) -> new ProductResponse(key, old.name(), old.price(), old.stock() + delta)));
}
public boolean delete(long id) {
return store.remove(id) != null;
}
}java
// Potongan — ProductController.java
package id.jalurjava.catalog;
import java.net.URI;
import java.util.List;
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 productService;
public ProductController(ProductService productService) { // constructor injection
this.productService = productService;
}
@GetMapping
public List<ProductResponse> search(@RequestParam(required = false) String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return productService.search(q, page, size);
}
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> get(@PathVariable long id) {
return ResponseEntity.of(productService.findById(id)); // Optional kosong → 404
}
@PostMapping
public ResponseEntity<ProductResponse> create(@RequestBody ProductRequest request) {
ProductResponse created = productService.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<ProductResponse> replace(@PathVariable long id,
@RequestBody ProductRequest request) {
return ResponseEntity.of(productService.replace(id, request));
}
@PatchMapping("/{id}/stock")
public ResponseEntity<ProductResponse> adjustStock(@PathVariable long id,
@RequestBody StockAdjustment body) {
return ResponseEntity.of(productService.adjustStock(id, body.delta()));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable long id) {
return productService.delete(id)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
}
// ProductRequest.java, ProductResponse.java, StockAdjustment.java
public record ProductRequest(String name, long price, int stock) {}
public record ProductResponse(long id, String name, long price, int stock) {}
public record StockAdjustment(int delta) {}ResponseEntity.of(optional) adalah jalan pintas untuk pola "200 bila ada, 404 bila kosong". Perhatikan setiap method controller hanya beberapa baris — semua keputusan bisnis ada di ProductService.
Uji manual dengan curl (-i menampilkan status dan header):
bash
# Buat produk → 201 + Location
curl -i -X POST localhost:8080/api/products \
-H 'Content-Type: application/json' \
-d '{"name":"Kopi Arabika 250 g","price":85000,"stock":12}'
# Ambil satu, cari, dan paging
curl -i localhost:8080/api/products/1
curl -i 'localhost:8080/api/products?q=kopi&page=0&size=10'
# Ganti seluruh data, lalu kurangi stok 2
curl -i -X PUT localhost:8080/api/products/1 \
-H 'Content-Type: application/json' \
-d '{"name":"Kopi Arabika 250 g","price":80000,"stock":12}'
curl -i -X PATCH localhost:8080/api/products/1/stock \
-H 'Content-Type: application/json' -d '{"delta":-2}'
# Hapus → 204, hapus lagi → 404
curl -i -X DELETE localhost:8080/api/products/1Respons POST kira-kira seperti ini (header lain dipangkas):
text
HTTP/1.1 201
Location: http://localhost:8080/api/products/1
Content-Type: application/json
{"id":1,"name":"Kopi Arabika 250 g","price":85000,"stock":12}Model ini sengaja masih polos: ProductRequest belum divalidasi (nama kosong atau harga negatif lolos begitu saja). Itu tugas bab berikutnya — DTO dan validasi.
Pertanyaan interview
1. Kapan memakai @PathVariable dan kapan @RequestParam?
@PathVariable untuk bagian URL yang mengidentifikasi resource, misalnya /products/42 atau /orders/{orderId}/items. @RequestParam untuk parameter opsional yang memodifikasi hasil: filter, pencarian, sorting, pagination (?status=PAID&page=2). Aturan praktis: bila tanpa nilai itu URL tidak lagi menunjuk resource yang sama, jadikan path variable.
2. Kenapa memakai ResponseEntity bila mengembalikan objek langsung sudah bisa?
Objek langsung selalu 200 OK. ResponseEntity memberi kontrol penuh atas status (201, 204, 404), header (Location, ETag, Cache-Control), dan body. Untuk endpoint yang statusnya selalu 200, mengembalikan objek langsung tetap sah dan lebih ringkas.
3. Apa yang dimaksud controller tipis, dan apa untungnya?
Controller hanya menerjemahkan HTTP ke pemanggilan service dan sebaliknya. Logika bisnis tinggal di service, sehingga bisa dipakai ulang oleh entry point lain (job terjadwal, consumer pesan), dites dengan unit test biasa, dan tidak bergantung pada detail HTTP. Controller tebal menyulitkan test dan membuat logika terduplikasi antar-endpoint.
Jebakan umum
- Lupa header
Content-Type: application/jsonsaat menguji dengancurl -d→415 Unsupported Media Type. @RequestParam(required = false) int page→ error saat parameter tidak dikirim karenanulltidak bisa jadiint. PakaidefaultValueatau tipeInteger.- Selalu mengembalikan
200, termasuk untuk resource yang tidak ditemukan atau baru dibuat. Klien jadi harus menebak dari isi body. - Logika bisnis dan penyimpanan di controller. Sulit dites dan tidak bisa dipakai ulang; pindahkan ke service.
- Menyimpan state di field controller tanpa memikirkan concurrency. Bean controller/service adalah singleton yang dipakai banyak thread; pakai struktur thread-safe seperti
ConcurrentHashMapatau simpan di database. - Hard-code host di header
Location("http://localhost:8080/..."). PakaiServletUriComponentsBuilder.
Ringkasan
@RestController=@Controller+@ResponseBody; nilai kembalian diserialisasi ke JSON.@RequestMappingdi class memberi prefix;@GetMapping,@PostMapping,@PutMapping,@PatchMapping,@DeleteMappingmemetakan method HTTP.@PathVariableuntuk identitas,@RequestParamuntuk filter/pagination (wajib secara default),@RequestBodyuntuk data JSON.ResponseEntitymengatur status dan header:created(uri)untuk201+Location,noContent(),notFound(),of(optional).- Controller tipis: urusan HTTP saja; aturan bisnis dan penyimpanan di service.
- Uji manual dengan
curl -idan jangan lupa headerContent-Type.