Setelah memahami dasar DSL, builder type-safe, dan @DslMarker, langkah berikutnya adalah merancang DSL yang idiomatik dan siap produksi. DSL yang baik menyeimbangkan keterbacaan, kekakuan, dan ekspresif: terlalu longgar dan pengguna mudah membuat kesalahan; terlalu kaku dan kode menjadi verbose. Pada chapter ini kita akan menyatukan konsep-konsep sebelumnya dengan teknik tambahan — extension function untuk discoverability, infix notation untuk sintaks natural, operator overloading untuk semantik ringkas, dan property delegate untuk state reaktif. Sebagai studi kasus, kita akan merancang DSL SQL-like sederhana yang memadukan keempat teknik tersebut.
API Surface dan Discoverability #
API surface DSL adalah daftar function, property, dan operator yang dipublikasikan ke pengguna. Prinsip utamanya: minimalkan permukaan publik, namun pastikan setiap item mudah ditemukan. Extension function adalah alat utama untuk mencapai keseimbangan ini. Daripada memasukkan semua method ke dalam class builder, kita dapat mendefinisikan method sebagai extension pada class lain, sehingga IDE hanya menampilkan method yang relevan untuk konteks tertentu.
Misalnya, dalam DSL SQL, method gt, eq, lt hanya relevan pada konteks where. Dengan mendefinisikannya sebagai infix fun String.gt(other: Int): Condition, IDE akan menyarankan method tersebut setelah pengguna mengetik "umur" di dalam block where { }.
Infix Notation untuk Sintaks Natural #
Keyword infix pada deklarasi function memungkinkan pemanggilan tanpa titik dan kurung. Sebagai ganti "umur".gt(20), kita dapat menulis "umur" gt 20 — sintaks yang menyerupai ekspresi biasa. Infix function harus menjadi method member atau extension function, dan harus menerima tepat satu parameter.
infix fun String.gt(other: Int): Condition = Condition(this, ">", other.toString())
infix fun String.eq(other: String): Condition = Condition(this, "=", "'$other'")
// Pemakaian dalam DSL:
where { "umur" gt 20 }
where { "kota" eq "Jakarta" }Infix function membentuk bahasa mini di dalam Kotlin tanpa perlu makro atau preprocessor. Keterbatasannya adalah harus mengikuti aturan prioritas operator Kotlin.
Operator Overloading dan Trade-offnya #
Kotlin memungkinkan kita mendefinisikan ulang operator seperti +, -, *, [] melalui function dengan nama khusus (operator fun plus(...), operator fun get(...)). Untuk DSL, operator overloading dapat membuat kode sangat ringkas, tetapi juga dapat menyesatkan jika dipakai sembarangan.
Contoh yang intuitif: QuerySet + QuerySet untuk menyatakan UNION dua query. Operator + sudah biasa dipakai untuk penggabungan (string, list, set), sehingga pengguna langsung memahami maksudnya. Sebaliknya, mendefinisikan * untuk join dua query akan janggal karena * identik dengan perkalian numerik.
Property Delegate untuk State Reaktif #
DSL yang kompleks sering kali memiliki state yang harus dipantau. Kotlin menyediakan property delegate by observable dan by vetoable di package kotlin.properties.Delegates. Delegate ini memungkinkan kita memicu callback setiap kali property berubah, atau bahkan menolak perubahan berdasarkan kondisi.
import kotlin.properties.Delegates
var maxRows: Int by Delegates.vetoable(100) { _, old, new ->
if (new > 1000) { println("Ditolak"); false } else true
}Dalam konteks DSL, property delegate cocok untuk validasi otomatis (menolak nilai ilegal), logging (mencatat setiap perubahan), dan reaktivitas (memberi tahu subscriber saat state berubah). Trade-off-nya adalah overhead method call pada setiap akses tulis.
Studi Kasus: SQL-like Query DSL #
Mari kita satukan keempat teknik di atas dalam satu DSL SQL-like. Builder select(table) { ... } menerima receiver lambda untuk konfigurasi kolom, kondisi where, dan limit. Kondisi where memanfaatkan infix function sehingga sintaks "umur" gt 20 terbaca natural. Operator + pada QuerySet menghasilkan UNION. Property delegate vetoable membatasi maxRows agar tidak melebihi batas wajar.
// internal_dsl_design.kt
// Internal DSL Design: SQL-like query DSL sederhana
// Menggabungkan extension function, infix notation, operator overloading,
// dan property delegate untuk menghasilkan DSL yang idiomatik.
import kotlin.properties.Delegates
// ===== 1) Infix function untuk kondisi where =====
// Membuat sintaks `"umur" gt 20` terbaca seperti ekspresi, bukan panggilan method.
infix fun String.gt(other: Int): Condition = Condition(this, ">", other.toString())
infix fun String.lt(other: Int): Condition = Condition(this, "<", other.toString())
infix fun String.eq(other: String): Condition = Condition(this, "=", "'$other'")
infix fun String.eq(other: Int): Condition = Condition(this, "=", other.toString())
data class Condition(val column: String, val op: String, val value: String)
// ===== 2) Builder SELECT dengan receiver lambda =====
class SelectBuilder(private val table: String) {
private val columns = mutableListOf<String>()
private val conditions = mutableListOf<Condition>()
private var limitValue: Int? = null
// Memungkinkan sintaks `columns("nama", "umur")`.
fun columns(vararg cols: String) {
columns.addAll(cols)
}
// Memungkinkan sintaks `where { "umur" gt 20 }`.
fun where(block: SelectBuilder.() -> Condition) {
conditions.add(this.block())
}
fun limit(n: Int) { limitValue = n }
fun build(): String {
val cols = if (columns.isEmpty()) "*" else columns.joinToString(", ")
val whereClause = if (conditions.isEmpty()) "" else
" WHERE " + conditions.joinToString(" AND ") { "${it.column} ${it.op} ${it.value}" }
val limitClause = limitValue?.let { " LIMIT $it" } ?: ""
return "SELECT $cols FROM $table$whereClause$limitClause;"
}
}
// Entry point DSL: `select("users") { ... }`.
fun select(table: String, block: SelectBuilder.() -> Unit): String {
val b = SelectBuilder(table)
b.block()
return b.build()
}
// ===== 3) Property delegate untuk state berubah =====
// `by observable` memicu callback saat nilai berubah;
// `by vetoable` dapat menolak perubahan berdasarkan kondisi.
class QuerySession {
// Hitung ulang counter setiap kali query dieksekusi.
var executedQueries: Int by Delegates.observable(0) { _, _, new ->
println(" [log] executedQueries sekarang = $new")
}
// Batasi limit maksimum ke 1000; tolak nilai lebih besar.
var maxRows: Int by Delegates.vetoable(100) { _, old, new ->
if (new > 1000) {
println(" [veto] maxRows $new terlalu besar, pertahankan $old")
false
} else true
}
}
// ===== 4) Operator overloading untuk menggabungkan query =====
// `+` antara dua QuerySet menghasilkan UNION.
data class QuerySet(val sql: String) {
operator fun plus(other: QuerySet): QuerySet =
QuerySet("${this.sql.dropLast(1)} UNION ${other.sql.dropLast(1)};")
}
fun main() {
// ===== Demo 1: Query sederhana tanpa where =====
val q1 = select("users") {
columns("id", "nama", "umur")
}
println("Query 1: $q1")
// ===== Demo 2: Query dengan where lewat infix =====
val q2 = select("users") {
columns("nama", "umur")
where { "umur" gt 20 }
limit(10)
}
println("Query 2: $q2")
// ===== Demo 3: Query dengan beberapa kondisi =====
val q3 = select("pelanggan") {
columns("nama", "kota", "total_belanja")
where { "kota" eq "Jakarta" }
limit(50)
}
println("Query 3: $q3")
// ===== Demo 4: Property delegate observable & vetoable =====
println()
println("=== Property Delegate ===")
val session = QuerySession()
session.executedQueries = 1
session.executedQueries = 2
session.maxRows = 500 // diterima
println("maxRows setelah 500: ${session.maxRows}")
session.maxRows = 2000 // ditolak (di atas 1000)
println("maxRows setelah 2000: ${session.maxRows}")
// ===== Demo 5: Operator overloading untuk menggabungkan query =====
val aktif = QuerySet(select("users") { where { "status" eq "aktif" } })
val vip = QuerySet(select("users") { where { "status" eq "vip" } })
val gabungan = aktif + vip
println()
println("=== Operator Overloading ===")
println(gabungan.sql)
}Output yang Dihasilkan:
Query 1: SELECT id, nama, umur FROM users;
Query 2: SELECT nama, umur FROM users WHERE umur > 20 LIMIT 10;
Query 3: SELECT nama, kota, total_belanja FROM pelanggan WHERE kota = 'Jakarta' LIMIT 50;
=== Property Delegate ===
[log] executedQueries sekarang = 1
[log] executedQueries sekarang = 2
maxRows setelah 500: 500
[veto] maxRows 2000 terlalu besar, pertahankan 500
maxRows setelah 2000: 500
=== Operator Overloading ===
SELECT * FROM users WHERE status = 'aktif' UNION SELECT * FROM users WHERE status = 'vip';Mari kita telaah baris-baris kode di atas:
- Baris 11 (
infix fun String.gt(other: Int): Condition): extension function dengan keywordinfix. Tipe receiverStringberarti function ini dapat dipanggil pada string kolom. Hasil pemanggilan adalah data classConditionyang membungkus kolom, operator, dan nilai. - Baris 19 (
class SelectBuilder(private val table: String)): class builder untuk query SELECT. Fieldtabledisimpan sebagaiprivate valkarena tidak boleh diubah pengguna setelah konstruksi. Field mutable (columns,conditions,limitValue) diisi melalui method builder. - Baris 33 (
fun where(block: SelectBuilder.() -> Condition)): parameterblockadalah receiver function type yang mengembalikanCondition. Di dalam block, pengguna menulis"umur" gt 20, yang menghasilkanConditionlalu ditambahkan ke daftar kondisi. - Baris 47 (
fun build(): String): function pembekuan yang mengkonversi state builder menjadi string SQL. Polabuild()mengembalikan tipe immutable (di siniString) adalah praktik terbaik — pengguna tidak dapat memutasikan hasil setelah dibangun. - Baris 56 (
fun select(table: String, block: SelectBuilder.() -> Unit): String): entry-point DSL. Pola konsisten dengan builder sebelumnya: buat instance builder, terapkan receiver lambda, kembalikan hasilbuild(). - Baris 67 (
var executedQueries: Int by Delegates.observable(0) { ... }): property dengan delegateobservable. Setiap assignment memicu lambda callback dengan parameterolddannew. Delegate ini cocok untuk logging dan reaktivitas. - Baris 73 (
var maxRows: Int by Delegates.vetoable(100) { ... }): property dengan delegatevetoable. Lambda mengembalikanBoolean—truemenerima perubahan,falsemenolak. Pola ini ideal untuk validasi batas. - Baris 86 (
operator fun plus(other: QuerySet): QuerySet): operator overloading+untukQuerySet. Hasil pemanggilan adalah string SQL denganUNIONdi antara dua query. Operator+dipilih karena sudah dikenal untuk penggabungan koleksi. - Baris 110 (
val gabungan = aktif + vip): pemanggilan operator yang menghasilkan gabungan SQL UNION. Pemakaian operator membuat kode terbaca seperti ekspresi aljabar relasional.
Contoh DSL di Ekosistem Kotlin #
Beberapa library populer menggunakan pola DSL Kotlin yang sama dengan yang kita pelajari di sini. Gradle Kotlin DSL menyediakan dependencies { implementation("...") } dengan receiver lambda pada DependencyHandler. Ktor routing menyarankan routing { get("/users") { ... } } dengan receiver lambda pada Route. Exposed SQL menyusun query seperti Users.select { Users.umur greater 20 } dengan kombinasi infix function dan extension property.
Trade-off Fleksibilitas vs Kekakuan #
DSL yang baik harus menentukan batas antara fleksibilitas dan kekakuan. Beberapa pedoman praktis: gunakan @DslMarker untuk membatasi receiver luar, minimalkan API publik dengan extension function untuk fitur sekunder, pilih infix untuk operasi biner natural, dan sediakan default yang masuk akal. SelectBuilder tanpa columns(...) menghasilkan SELECT * agar pengguna tidak wajib menulis konfigurasi lengkap.
Referensi Lanjutan #
- Operator overloading — panduan resmi cara mendefinisikan operator kustom di Kotlin
- Scope functions
—
apply,run, danwithsebagai contoh pola receiver lambda di standard library - Gradle Kotlin DSL — contoh DSL Kotlin dalam ekosistem build tool
Penutup #
Internal DSL adalah puncak penerapan fitur bahasa Kotlin: extension function, receiver lambda, infix notation, operator overloading, dan property delegate bersatu menjadi bahasa mini yang aman dan ekspresif. Kita telah merancang DSL SQL-like yang menghasilkan query valid lewat sintaks deklaratif, sambil memanfaatkan property delegate untuk validasi otomatis. Dengan bekal ini, kita siap mengenali, membaca, dan merancang DSL di library Kotlin populer seperti Gradle, Ktor, dan Exposed. Section DSL berakhir di sini — pada section berikutnya, kita akan beralih ke Error Handling, topik yang penting agar program Kotlin kita tetap tangguh saat menghadapi situasi tak terduga.