PingController di bab sebelumnya sengaja dibuat sesederhana mungkin supaya kita fokus ke dependency injection. Sekarang kita naik level: OrderController.kt, controller yang benar-benar menangani resource Order — endpoint yang nanti dipakai client sungguhan untuk membuat dan mencari order.
@RestController vs @Controller
Kalau kamu pernah lihat Spring MVC versi lama (atau aplikasi yang render HTML dari server), kamu mungkin familiar dengan @Controller yang mengembalikan nama view untuk di-render jadi halaman HTML. @RestController berbeda: dia adalah gabungan @Controller + @ResponseBody, artinya setiap nilai yang di-return oleh method di dalamnya langsung diserialisasi jadi response body (biasanya JSON lewat Jackson), bukan diinterpretasikan sebagai nama view. Untuk backend yang murni jadi REST API — seperti seluruh service di seri ini — @RestController adalah pilihan default.
Anatomy OrderController
@RestController
@RequestMapping("/api/orders")
class OrderController(
private val orderService: OrderService,
private val inventoryClient: InventoryClient
) {
// ...
}
@RequestMapping("/api/orders") di level kelas menetapkan prefix bersama untuk semua endpoint di controller ini — tidak perlu menulis /api/orders berulang-ulang di setiap method, cukup path tambahan (kalau ada) yang ditulis di anotasi masing-masing method. Constructor-nya menyuntikkan OrderService (yang akan kita pakai di bab ini) dan InventoryClient (dipakai endpoint checkInventory — bagian dari Fase 4 nanti, kita lewati dulu).
Fokus kita di bab ini ada tiga method: createOrder, getOrder, dan searchOrders.
createOrder — POST /api/orders
@PostMapping
fun createOrder(@Valid @RequestBody request: CreateOrderRequest): ResponseEntity<OrderResponse> {
val order = orderService.createOrder(request)
return ResponseEntity.status(HttpStatus.CREATED).body(order.toResponse())
}
@PostMapping tanpa argumen berarti method ini menangani POST persis di path dasar controller, yaitu POST /api/orders. @RequestBody memberi tahu Spring: ambil body request (JSON), lalu deserialisasi jadi object CreateOrderRequest — kita akan bahas bentuk DTO ini di bab berikutnya. @Valid di depannya memicu validasi otomatis terhadap object itu sebelum masuk ke body method; detailnya kita bahas tuntas di bab tentang validasi.
Yang penting dicermati di sini adalah pola delegasi: controller tidak menghitung total harga, tidak menentukan status order, tidak menyentuh database sama sekali. Semua logic itu didelegasikan ke orderService.createOrder(request). Controller cuma bertugas: terima request, panggil service yang tepat, bungkus hasilnya jadi response. Pemisahan ini penting supaya controller tetap tipis dan gampang dibaca — kalau logic bisnis berubah, kita ubah OrderService, bukan bongkar-bongkar routing.
getOrder — GET /api/orders/{id}
@GetMapping("/{id}")
fun getOrder(@PathVariable id: Long): ResponseEntity<OrderResponse> {
val order = orderService.getOrderById(id)
return ResponseEntity.ok(order.toResponse())
}
{id} di path adalah placeholder — bagian dinamis dari URL. @PathVariable id: Long menangkap nilai itu dari URL dan otomatis mengonversinya ke Long. Request ke GET /api/orders/5 akan membuat id bernilai 5. Kalau order dengan id itu tidak ditemukan, orderService.getOrderById(id) akan melempar OrderNotFoundException — kita akan lihat bagaimana exception ini ditangani secara konsisten di bab terakhir Fase 1 ini.
searchOrders — GET /api/orders?customerName=...
@GetMapping
fun searchOrders(@RequestParam(required = false) customerName: String?): List<OrderResponse> =
if (customerName.isNullOrBlank()) {
emptyList()
} else {
orderService.searchByCustomerName(customerName).map { it.toResponse() }
}
Berbeda dari @PathVariable yang menangkap bagian dari struktur URL, @RequestParam menangkap query string — bagian setelah tanda ? di URL, misalnya customerName pada /api/orders?customerName=Budi. required = false membuat parameter ini opsional; kalau client tidak mengirimkannya, Spring tidak akan menolak request dengan error, dan customerName di dalam method akan bernilai null — makanya tipenya ditulis nullable, String?.
Perhatikan juga method ini tidak dianotasi path tambahan pada @GetMapping, jadi dia menangani GET /api/orders polos (tanpa {id}), dibedakan dari getOrder di atas berdasarkan method HTTP dan bentuk path, bukan nama method Kotlin-nya. Kalau customerName kosong atau tidak dikirim, kita sengaja mengembalikan list kosong daripada mengembalikan semua order — keputusan desain untuk mencegah endpoint pencarian tanpa filter tidak sengaja jadi "ambil semua data" yang berat di database nanti.
Endpoint Tambahan: createOrdersBulk
Kamu akan lihat satu method lagi di file yang sama, createOrdersBulk (POST /api/orders/bulk), yang menerima list CreateOrderRequest sekaligus. Ini endpoint tambahan yang kita bahas lebih detail nanti — untuk sekarang cukup tahu bahwa dia ada dan mengikuti pola yang sama: terima request, delegasikan ke OrderService, bungkus hasil.
Kenapa ResponseEntity?
Perhatikan createOrder dan getOrder sama-sama mengembalikan ResponseEntity<OrderResponse>, sedangkan searchOrders cukup mengembalikan List<OrderResponse> polos. Keduanya valid — bedanya soal kontrol.
Kalau method cuma mengembalikan object/list biasa, Spring otomatis membalas dengan status 200 OK. Itu cocok untuk searchOrders, karena hasil pencarian (baik ketemu maupun kosong) memang selalu direspons dengan status sukses yang sama. Tapi untuk createOrder, secara konvensi REST, order yang berhasil dibuat semestinya direspons dengan status 201 Created, bukan 200 OK — ini menandakan ke client bahwa ada resource baru yang tercipta di server. ResponseEntity memberi kita kendali eksplisit atas hal ini:
ResponseEntity.status(HttpStatus.CREATED).body(order.toResponse())
ResponseEntity<T> membungkus body response bersama status code (dan kalau perlu, header) dalam satu object, jadi kita bisa memilih persis status apa yang dikirim, bukan berharap Spring menebak dengan benar. getOrder memakai ResponseEntity.ok(...) — shortcut untuk status 200 OK — karena mengambil order yang sudah ada memang selalu direspons sukses biasa (kasus "tidak ditemukan" ditangani lewat exception, bukan lewat return value di sini).
Routing dan controller sudah jelas polanya. Tapi kita sengaja belum bahas detail CreateOrderRequest dan OrderResponse yang lalu-lalang di sini — dua data class itu jadi topik utama bab berikutnya: bagaimana DTO dirancang terpisah dari model domain, dan kenapa itu penting.
Bagian dari Series: Kotlin Backend Microservice - Belajar Fundamental sampai Microservice Nyata
Belajar Kotlin + Spring Boot dari fundamental bahasa (null safety, OOP, coroutines) sampai membangun sistem microservice sungguhan - REST API, Postgre...
Lihat Series Lengkap