Aplikasi multiplatform yang baik dimulai dari satu pertanyaan: bagian mana yang benar-benar umum lintas platform, dan bagian mana yang harus spesifik? Pada Bagian 1 ini kita menulis shared domain logic di commonMain — kode murni yang tidak bergantung pada API platform apapun. Hanya Kotlin stdlib (yang tersedia di semua target) yang dipakai. Domain model, business rule, dan use case hidup di sini, dapat dipakai ulang di JVM, Android, iOS, dan JavaScript tanpa modifikasi.

commonMain: Apa yang Boleh Dipakai #

commonMain adalah source set yang dikompilasi untuk semua platform target. Karena harus berjalan di mana saja, hanya library multiplatform yang boleh dipakai:

  • Kotlin stdlib (multiplatform) — List, Map, Regex, Result, data class, enum, object, interface.
  • kotlinx.serialization — JSON/CBOR/Proto multiplatform.
  • kotlinx.coroutines — multiplatform, dengan dispatcher per platform.
  • kotlinx-datetime — abstraksi waktu multiplatform.
  • kotlin.test — multiplatform test runner.

Yang tidak boleh dipakai di commonMain:

  • java.io.File, java.sql.*, java.net.* — spesifik JVM.
  • android.util.Log, android.content.* — spesifik Android.
  • platform.*, kotlinx.cinterop.* — spesifik Kotlin/Native.

Akses ke API platform-specific harus melalui expect/actual (akan dibahas di Bagian 3) atau interface yang di-impl per platform (akan dibahas di Bagian 2).

Domain Model dan Business Rule #

Kita mulai dengan domain model pencatat keuangan: Transaksi, JenisTransaksi, dan DomainError. Semua adalah data class, enum class, atau sealed class — konstruk yang tersedia di commonMain:

kotlin Domain model di commonMain
data class Transaksi(
    val id: Long,
    val jenis: JenisTransaksi,
    val jumlah: Long,
    val kategori: String,
    val catatan: String,
    val tanggalIso: String
)

enum class JenisTransaksi { PEMASUKAN, PENGELUARAN }

sealed class DomainError(pesan: String) : Throwable(pesan) {
    class JumlahTidakValid(pesan: String = "Jumlah harus positif") : DomainError(pesan)
    class KategoriKosong(pesan: String = "Kategori wajib diisi") : DomainError(pesan)
    class TanggalInvalid(pesan: String = "Format tanggal harus ISO yyyy-MM-dd") : DomainError(pesan)
}

sealed class DomainError membuat when di layer caller menjadi exhaustive — kompilator memperingatkan jika satu subkelas belum ditangani. DomainError extends Throwable agar dapat dibungkus dalam Result.failure().

Implementasi Shared Domain #

Kode berikut mendemonstrasikan shared domain logic lengkap: model, validator, repository interface, use case, dan demo runner:

kotlin
// Bagian 1: Shared domain logic di commonMain.
// Mendemonstrasikan kode domain murni yang tidak bergantung pada
// platform-specific API. Hanya pakai Kotlin stdlib yang tersedia di
// commonMain.

// === commonMain: domain model ===
data class Transaksi(
    val id: Long,
    val jenis: JenisTransaksi,
    val jumlah: Long,
    val kategori: String,
    val catatan: String,
    val tanggalIso: String  // ISO yyyy-MM-dd
)

enum class JenisTransaksi { PEMASUKAN, PENGELUARAN }

// Domain error: tidak bergantung platform
sealed class DomainError(pesan: String) : Throwable(pesan) {
    class JumlahTidakValid(pesan: String = "Jumlah harus positif") : DomainError(pesan)
    class KategoriKosong(pesan: String = "Kategori wajib diisi") : DomainError(pesan)
    class TanggalInvalid(pesan: String = "Format tanggal harus ISO yyyy-MM-dd") : DomainError(pesan)
}

// === commonMain: business rule ===
// Pure function: tidak ada efek samping, hanya mengubah state.
object ValidatorTransaksi {
    private val ISO_RE = Regex("""^\d{4}-\d{2}-\d{2}$""")

    fun validasi(jenis: JenisTransaksi, jumlah: Long, kategori: String, tanggalIso: String): Result<Transaksi> {
        if (jumlah <= 0) return Result.failure(DomainError.JumlahTidakValid())
        if (kategori.isBlank()) return Result.failure(DomainError.KategoriKosong())
        if (!ISO_RE.matches(tanggalIso)) return Result.failure(DomainError.TanggalInvalid())
        // (di kode nyata: tambahkan validasi tanggal valid via kotlinx-datetime)
        return Result.success(
            Transaksi(0, jenis, jumlah, kategori.trim(), "", tanggalIso)
        )
    }
}

// === commonMain: repository interface ===
// Implementasi di source set spesifik platform.
interface TransaksiRepo {
    fun simpan(t: Transaksi): Transaksi   // mengembalikan dengan ID baru
    fun semua(): List<Transaksi>
    fun cariByKategori(kategori: String): List<Transaksi>
}

// In-memory implementation untuk demo dan test di commonTest
class TransaksiRepoInMemory : TransaksiRepo {
    private val daftar = mutableListOf<Transaksi>()
    private var urutan = 0L

    override fun simpan(t: Transaksi): Transaksi {
        urutan += 1
        val denganId = t.copy(id = urutan)
        daftar.add(denganId)
        return denganId
    }
    override fun semua(): List<Transaksi> = daftar.toList()
    override fun cariByKategori(kategori: String): List<Transaksi> =
        daftar.filter { it.kategori.equals(kategori, ignoreCase = true) }
}

// === commonMain: use case / application service ===
class BukuKeuangan(private val repo: TransaksiRepo) {
    fun catatPemasukan(jumlah: Long, kategori: String, tanggalIso: String): Result<Transaksi> {
        val hasil = ValidatorTransaksi.validasi(JenisTransaksi.PEMASUKAN, jumlah, kategori, tanggalIso)
        return hasil.map { repo.simpan(it) }
    }

    fun catatPengeluaran(jumlah: Long, kategori: String, tanggalIso: String): Result<Transaksi> {
        val hasil = ValidatorTransaksi.validasi(JenisTransaksi.PENGELUARAN, jumlah, kategori, tanggalIso)
        return hasil.map { repo.simpan(it) }
    }

    fun ringkasan(): RingkasanKeuangan {
        val data = repo.semua()
        val masuk = data.filter { it.jenis == JenisTransaksi.PEMASUKAN }.sumOf { it.jumlah }
        val keluar = data.filter { it.jenis == JenisTransaksi.PENGELUARAN }.sumOf { it.jumlah }
        return RingkasanKeuangan(masuk, keluar, data.size)
    }

    fun byKategori(kategori: String): List<Transaksi> = repo.cariByKategori(kategori)
}

data class RingkasanKeuangan(
    val totalPemasukan: Long,
    val totalPengeluaran: Long,
    val jumlahTransaksi: Int
) {
    val saldo: Long get() = totalPemasukan - totalPengeluaran
}

// === Demo runner (sama untuk JVM/Android/iOS karena pakai commonMain) ===
fun main() {
    val buku = BukuKeuangan(TransaksiRepoInMemory())

    println("=== Shared domain logic (commonMain) ===")
    println("Berjalan identik di JVM, Android, iOS, JS")

    println()
    println("=== Catat transaksi valid ===")
    buku.catatPemasukan(5_000_000, "Gaji", "2026-01-05")
        .onSuccess { println("  Tersimpan: [${it.id}] PEMASUKAN Rp${it.jumlah} ${it.kategori}") }
        .onFailure { println("  Gagal: ${it.message}") }

    buku.catatPengeluaran(750_000, "Makan", "2026-01-08")
        .onSuccess { println("  Tersimpan: [${it.id}] PENGELUARAN Rp${it.jumlah} ${it.kategori}") }
        .onFailure { println("  Gagal: ${it.message}") }

    buku.catatPengeluaran(1_200_000, "Transport", "2026-01-12")
        .onSuccess { println("  Tersimpan: [${it.id}] PENGELUARAN Rp${it.jumlah} ${it.kategori}") }

    println()
    println("=== Catat transaksi invalid ===")
    buku.catatPemasukan(-100, "Gaji", "2026-01-15")
        .onSuccess { println("  Tersimpan? $it") }
        .onFailure { println("  Gagal (jumlah negatif): ${it.message}") }

    buku.catatPemasukan(500_000, "", "2026-01-15")
        .onSuccess { println("  Tersimpan? $it") }
        .onFailure { println("  Gagal (kategori kosong): ${it.message}") }

    buku.catatPemasukan(500_000, "Bonus", "15-01-2026")
        .onSuccess { println("  Tersimpan? $it") }
        .onFailure { println("  Gagal (tanggal salah format): ${it.message}") }

    println()
    println("=== Ringkasan ===")
    val r = buku.ringkasan()
    println("  Total Pemasukan  : Rp${r.totalPemasukan}")
    println("  Total Pengeluaran : Rp${r.totalPengeluaran}")
    println("  Saldo             : Rp${r.saldo}")
    println("  Jumlah transaksi  : ${r.jumlahTransaksi}")

    println()
    println("=== Filter kategori 'Makan' ===")
    buku.byKategori("makan").forEach { t ->
        println("  [${t.id}] Rp${t.jumlah} ${t.kategori}")
    }
}

Output yang Dihasilkan:

text
=== Shared domain logic (commonMain) ===
Berjalan identik di JVM, Android, iOS, JS

=== Catat transaksi valid ===
  Tersimpan: [1] PEMASUKAN Rp5000000 Gaji
  Tersimpan: [2] PENGELUARAN Rp750000 Makan
  Tersimpan: [3] PENGELUARAN Rp1200000 Transport

=== Catat transaksi invalid ===
  Gagal (jumlah negatif): Jumlah harus positif
  Gagal (kategori kosong): Kategori wajib diisi
  Gagal (tanggal salah format): Format tanggal harus ISO yyyy-MM-dd

=== Ringkasan ===
  Total Pemasukan  : Rp5000000
  Total Pengeluaran : Rp1950000
  Saldo             : Rp3050000
  Jumlah transaksi  : 3

=== Filter kategori 'Makan' ===
  [2] Rp750000 Makan

Mari kita bedah komponen utama:

  1. sealed class DomainError : Throwable: domain error yang bersifat exhaustive. Karena extends Throwable, dapat dipakai dengan Result.failure(). Saat when (error) digunakan di caller, kompilator memaksa semua sub-kelas (JumlahTidakValid, KategoriKosong, TanggalInvalid) ditangani.
  2. Regex("""^\d{4}-\d{2}-\d{2}$"""): validasi format ISO. Regex tersedia di commonMain. Pola ^\d{4}-\d{2}-\d{2}$ memastikan tepat 4 digit tahun, dash, 2 digit bulan, dash, 2 digit tanggal. Pencocokan dengan matches() memeriksa seluruh string, bukan substring.
  3. Result<Transaksi> + hasil.map { repo.simpan(it) }: monad Result memungkinkan chaining. Jika validasi gagal, map tidak dipanggil dan failure diteruskan. Pola ini menghindari try/catch eksplisit dan membuat alur data jelas.
  4. interface TransaksiRepo: abstraksi repository. Implementasi TransaksiRepoInMemory untuk demo/test. Di Bagian 2, repository akan punya implementasi per platform (SQLite di JVM, Room di Android, SQLDelight di iOS).
  5. BukuKeuangan(repo): use case yang memakai repository via interface. Tidak tahu platform apa yang memakainya; hanya bergantung pada interface yang dipasang dari luar (dependency injection).

Pure Function dan Testabilitas #

ValidatorTransaksi.validasi() adalah pure function: input yang sama selalu menghasilkan output yang sama, tidak ada efek samping. Pure function mudah diuji:

kotlin Test pure function
@Test
fun `jumlah negatif mengembalikan JumlahTidakValid`() {
    val hasil = ValidatorTransaksi.validasi(JenisTransaksi.PEMASUKAN, -100, "Gaji", "2026-01-15")
    assertTrue(hasil.isFailure)
    assertTrue(hasil.exceptionOrNull() is DomainError.JumlahTidakValid)
}

Test ini berjalan identik di commonTest, jvmTest, androidTest, dan iosTest. Sekali tulis, sekali jalan di semua platform.

Repository Pattern untuk Multiplatform #

Interface TransaksiRepo di commonMain adalah kontrak yang harus dihormati semua platform. Setiap platform menyediakan implementasi yang berbeda:

  • JVM (jvmMain): TransaksiRepoJdbc yang memakai JDBC + SQLite.
  • Android (androidMain): TransaksiRepoRoom yang memakai Room.
  • iOS (iosMain): TransaksiRepoSqlDelight yang memakai SQLDelight.

Use case BukuKeuangan tidak tahu implementasi mana yang dipakai; cukup menerima TransaksiRepo. Pola ini disebut dependency inversion: high-level module (use case) tidak bergantung pada low-level module (database spesifik platform); keduanya bergantung pada abstraksi.

Referensi Lanjutan #

  • Kotlin Multiplatform — halaman utama KMP untuk merancang commonMain yang berisi shared domain logic

Ringkasan Bagian 1 #

Shared domain logic kini siap di commonMain: Transaksi model, DomainError sealed class, ValidatorTransaksi pure function, TransaksiRepo interface, dan BukuKeuangan use case. Semua kode berjalan identik di setiap platform tanpa modifikasi. Pada Bagian 2 kita menambahkan multiplatform data layer dengan implementasi TransaksiRepo per platform.