M
Mr Sugiarto
Developer
06 Sep 2026 5 min read

Di bab sebelumnya kita sudah lihat bagaimana Kotlin menjamin null safety di level compiler. Sekarang kita lanjut ke topik yang akan sangat sering kita pakai begitu mulai membangun Order Service secara nyata di Fase 1: bagaimana Kotlin memodelkan data dan state lewat OOP — khususnya tiga fitur yang jarang ada persamaan persisnya di bahasa lain: data class, sealed class, dan object.

Kenapa Bukan class Biasa Saja?

Di kebanyakan bahasa OOP, membuat sebuah class untuk membungkus data (misalnya representasi uang, atau hasil sebuah operasi) berarti kamu harus menulis constructor, getter, equals(), hashCode(), dan toString() secara manual — atau mengandalkan generator IDE yang tetap menghasilkan puluhan baris boilerplate yang harus di-scroll setiap kali membaca class itu. Kotlin punya data class yang menggenerate semuanya otomatis dari satu baris deklarasi.

Kita bahas ini lewat OopBasicsTest.kt, test file asli di project order-service.

data class: Equality Berdasarkan Isi, Bukan Referensi

// data class: equals/hashCode/toString/copy digenerate otomatis dari primary constructor.
data class Money(val amount: Long, val currency: String = "IDR")

Satu baris ini sudah memberi kita: constructor dengan dua parameter (currency punya default value "IDR", jadi opsional), toString() yang human-readable, serta equals()/hashCode() yang membandingkan berdasarkan isi field, bukan referensi object di memory. Ini terbukti langsung di test pertama:

@Test
fun `data class membandingkan berdasarkan isi field, bukan referensi`() {
    val a = Money(50_000)
    val b = Money(50_000)
    assertEquals(a, b)
    assertNotEquals(a, Money(75_000))
}

a dan b adalah dua instance Money yang berbeda di memory, tapi assertEquals(a, b) tetap lolos karena isinya sama. Di class biasa (tanpa override equals() manual), a == b akan false karena yang dibandingkan adalah alamat referensi, bukan isi — perilaku yang sering jadi sumber bug halus saat membandingkan dua objek DTO yang "seharusnya" sama.

copy(): Salinan dengan Sebagian Field Berubah

Karena instance data class biasanya dibuat dari val (immutable), Kotlin menyediakan method copy() untuk membuat salinan baru dengan sebagian field diubah, tanpa memutasi instance asli:

@Test
fun `copy membuat salinan dengan sebagian field diubah`() {
    val original = Money(50_000)
    val discounted = original.copy(amount = 45_000)

    assertEquals(45_000, discounted.amount)
    assertEquals("IDR", discounted.currency)
    assertEquals(50_000, original.amount, "instance asli tidak berubah")
}

Pola ini akan sering kamu lihat lagi begitu kita memodelkan DTO request/response di Fase 1 — misalnya mengubah status sebuah order tanpa menyentuh field lain, dengan cara order.copy(status = OrderStatus.SHIPPED).

sealed class: Compiler Memaksa Semua Kemungkinan Ditangani

Bagian paling menarik dari file ini adalah sealed class. Bayangkan kita perlu memodelkan hasil sebuah proses pembayaran — bisa disetujui, ditolak, atau masih diproses. Di banyak bahasa, ini biasanya dimodelkan dengan enum plus field opsional, atau exception untuk kasus gagal — keduanya rawan lolos tanpa ditangani lengkap. Kotlin punya sealed class, yang membatasi turunannya hanya boleh dideklarasikan di dalam file yang sama:

// sealed class: seluruh kemungkinan turunan diketahui compiler di compile-time.
sealed class PaymentResult {
    data class Approved(val transactionId: String) : PaymentResult()
    data class Declined(val reason: String) : PaymentResult()
    object Pending : PaymentResult()
}

Tiga turunan ini masing-masing membawa data yang berbeda: Approved butuh transactionId, Declined butuh alasan penolakan, sedangkan Pending tidak butuh data tambahan sama sekali — jadi dia dideklarasikan sebagai object, bukan data class (kita bahas ini di bawah).

Karena compiler tahu seluruh kemungkinan turunan PaymentResult di compile-time, when yang mencocokkan tipe ini menjadi exhaustive — compiler akan menolak kompilasi kalau ada satu cabang yang lupa ditangani, tanpa perlu klausa else:

private fun describeResult(result: PaymentResult): String = when (result) {
    is PaymentResult.Approved -> "Disetujui, id transaksi ${result.transactionId}"
    is PaymentResult.Declined -> "Ditolak: ${result.reason}"
    PaymentResult.Pending -> "Masih diproses"
    // Tidak perlu `else` - compiler tahu ketiga cabang di atas sudah cover semua turunan sealed class.
}

Ini beda mendasar dibanding pendekatan berbasis exception atau enum-plus-else: kalau suatu hari kita menambah turunan baru — katakanlah PaymentResult.Refunded — kode describeResult ini akan gagal kompilasi sampai kita menambahkan cabang untuk itu. Bandingkan dengan pendekatan else generik: menambah kasus baru bisa lolos kompilasi tanpa ada yang sadar bahwa kasus baru itu jatuh ke cabang default yang salah — bug yang baru ketahuan saat ada transaksi nyata berstatus Refunded diperlakukan seolah masih Pending. Untuk domain seperti pembayaran, di mana menangani kasus dengan salah bisa berarti duit hilang atau order tidak terkirim, jaminan compiler ini bukan hal kosmetik.

Test-nya memverifikasi ketiga cabang:

@Test
fun `sealed class memaksa semua kemungkinan hasil ditangani`() {
    assertEquals("Disetujui, id transaksi TRX-001", describeResult(PaymentResult.Approved("TRX-001")))
    assertEquals("Ditolak: saldo tidak cukup", describeResult(PaymentResult.Declined("saldo tidak cukup")))
    assertEquals("Masih diproses", describeResult(PaymentResult.Pending))
}

object: Singleton untuk Kasus Tanpa Data

Perhatikan lagi baris object Pending : PaymentResult(). Kata kunci object di Kotlin mendeklarasikan sebuah class yang otomatis punya tepat satu instance — tidak ada constructor, tidak ada cara membuat instance kedua. Ini pas untuk Pending, karena status "masih diproses" tidak butuh data pembeda sama sekali; semua order yang statusnya "pending" cukup direpresentasikan satu instance yang sama, dibandingkan dengan == seperti biasa. Kalau Pending dideklarasikan sebagai data class biasa (tanpa parameter), kita tetap harus menulis PaymentResult.Pending() dengan tanda kurung setiap kali dipakai; sebagai object, cukup PaymentResult.Pending tanpa kurung — sekaligus menegaskan secara sintaks bahwa ini bukan sesuatu yang di-instantiate berulang.

Modal data class dan sealed class ini langsung jadi fondasi bab berikutnya: functional programming di Kotlin, di mana kita akan sering memproses koleksi objek data class seperti ini lewat fungsi-fungsi seperti map dan filter.

M
Mr Sugiarto

Developer

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