Di bab lalu kita sudah lihat OrderController melempar-lemparkan dua tipe: CreateOrderRequest sebagai input dan OrderResponse sebagai output, dengan sebuah order.toResponse() di tengah-tengahnya. Sekarang kita bongkar OrderDto.kt — tempat ketiganya didefinisikan — dan kenapa bentuknya sengaja dipisah dari model domain Order sendiri.
Kenapa DTO Terpisah dari Domain Model?
DTO (Data Transfer Object) adalah kelas yang tugasnya murni membawa data melintasi batas sistem — di kasus kita, melintasi batas HTTP antara client dan server. Godaan yang sering muncul: "kenapa tidak langsung pakai entity Order saja sebagai request/response, kan strukturnya mirip-mirip?" Alasannya ada di kata kontrak.
Order (entity yang akan kita definisikan detail di Fase 2, saat masuk ke JPA) merepresentasikan struktur tabel di database — dia terikat pada kebutuhan penyimpanan: kolom apa yang ada, tipe data apa yang efisien untuk index, relasi ke tabel lain, dst. CreateOrderRequest dan OrderResponse merepresentasikan API contract — bentuk JSON yang dijanjikan ke client, dan bentuk itu semestinya stabil selama mungkin, karena ada aplikasi lain (mobile app, frontend, service lain) yang bergantung padanya.
Kalau keduanya digabung jadi satu kelas, setiap perubahan kecil di database — menambah kolom internal, mengganti tipe data, merename field demi konsistensi schema — otomatis ikut mengubah bentuk API tanpa kita sadar, berpotensi mematahkan client yang sudah terlanjur mengintegrasikan API lama. Dengan DTO terpisah, kita bebas mengubah struktur database kapan saja, selama mapping ke DTO tetap dijaga konsisten — perubahan internal tidak pernah bocor keluar sebagai breaking change di API publik.
CreateOrderRequest — Bentuk Request
data class CreateOrderRequest(
@field:NotBlank(message = "customerName tidak boleh kosong")
val customerName: String,
@field:NotBlank(message = "itemName tidak boleh kosong")
val itemName: String,
@field:Min(value = 1, message = "quantity minimal 1")
val quantity: Int,
@field:DecimalMin(value = "0.01", message = "unitPrice harus lebih dari 0")
val unitPrice: BigDecimal
)
Ini bentuk JSON yang diharapkan server saat client memanggil POST /api/orders — empat field: nama customer, nama item, jumlah, dan harga satuan. Kamu akan lihat setiap field ini juga punya anotasi seperti @field:NotBlank dan @field:Min — itu anotasi validasi. Kita sengaja tidak bahas detailnya di sini; itu topik utuh bab berikutnya. Untuk sekarang, cukup lihat bentuk kelasnya: sebuah data class empat field, persis seperti bentuk JSON yang mau diterima.
OrderResponse — Bentuk Response
data class OrderResponse(
val id: Long,
val customerName: String,
val itemName: String,
val quantity: Int,
val unitPrice: BigDecimal,
val totalPrice: BigDecimal,
val status: String
)
Perhatikan OrderResponse punya field yang tidak ada di CreateOrderRequest: id (identitas order setelah tersimpan di database), totalPrice (dihitung server, bukan dikirim client), dan status (state order, ditentukan oleh business logic di OrderService). Ini contoh nyata kenapa request dan response DTO tidak selalu simetris — masing-masing merepresentasikan arah data yang berbeda, dan boleh punya bentuk yang berbeda pula.
toResponse() — Extension Function sebagai Mapper
fun Order.toResponse() = OrderResponse(
id = id,
customerName = customerName,
itemName = itemName,
quantity = quantity,
unitPrice = unitPrice,
totalPrice = totalPrice,
status = status.name
)
Di sinilah Order (entity/domain model, akan kita telusuri strukturnya lebih detail begitu masuk Fase 2) dan OrderResponse (DTO) bertemu: sebuah extension function bernama toResponse() yang "ditempelkan" ke tipe Order, mengembalikan OrderResponse yang sepadan. Kalian sudah lihat pola serupa di Fase 0 (bab 3, seputar fungsi dan functional programming) — extension function memungkinkan kita menambahkan method baru ke sebuah tipe tanpa mengubah kelas aslinya atau membuat subclass. Di sini, manfaatnya jadi jelas: alih-alih menulis mapping manual OrderResponse(id = order.id, customerName = order.customerName, ...) di setiap controller yang butuh, kita tulis sekali sebagai order.toResponse() yang bisa dipanggil di mana saja — persis yang kita lihat dipakai di createOrder, getOrder, dan searchOrders pada bab sebelumnya (order.toResponse() atau it.toResponse() di dalam .map { }).
Satu detail kecil yang gampang terlewat: status = status.name. Field status di Order kemungkinan besar bertipe enum (misalnya OrderStatus.CONFIRMED), sedangkan OrderResponse.status bertipe String — .name mengonversi konstanta enum jadi representasi string-nya. Ini contoh lain kenapa DTO terpisah berguna: representasi internal (enum, type-safe, dicek compiler) tidak harus identik dengan representasi di wire format JSON (string biasa, lebih universal untuk dikonsumsi client apa pun).
Jackson 3 Menyerialisasi Tanpa Konfigurasi Tambahan
Satu hal yang mungkin kamu sadari: kita tidak menulis satu baris kode pun untuk mengubah OrderResponse jadi JSON. Ini kerja Jackson — library serialisasi JSON yang otomatis dipasang Spring Boot lewat starter web. Project ini secara spesifik memakai Jackson 3 (perhatikan dependency tools.jackson.module:jackson-module-kotlin di build.gradle.kts — package barunya tools.jackson, bukan lagi com.fasterxml.jackson seperti Jackson 2), mesin JSON baru yang dipakai Spring Boot 4.
Karena OrderResponse adalah data class, setiap property-nya otomatis punya getter yang mengikuti konvensi Java Bean (id → getId(), customerName → getCustomerName(), dst di level bytecode) — dan itu persis yang dicari Jackson secara default saat menyerialisasi sebuah object jadi JSON. Hasilnya: data class biasa, tanpa satu pun anotasi Jackson (@JsonProperty dkk), sudah otomatis menghasilkan JSON dengan key yang sesuai nama property Kotlin-nya. jackson-module-kotlin menambal beberapa kasus khusus Kotlin lainnya (constructor dengan default value, data class dengan property nullable, dst) yang tidak selalu ditangani mulus oleh Jackson generik.
DTO memberi kita kontrak API yang stabil dan terpisah dari database. Tapi ada satu lubang yang belum kita tutup: apa yang terjadi kalau client mengirim quantity: -5 atau customerName: ""? Field-field ini punya anotasi validasi yang kita bahas detail di bab berikutnya — saatnya membahas Bean Validation secara tuntas.
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