Domain logic di Bagian 1 menyimpan transaksi di in-memory list yang hilang saat aplikasi ditutup. Pada Bagian 2 kita membangun data layer multiplatform dengan beberapa implementasi TransaksiRepo: in-memory untuk testing, JDBC untuk JVM, dan pola yang sama untuk Android (Room) serta iOS (SQLDelight). Use case BukuKeuangan tetap tidak peduli platform mana yang dipakai — ia hanya menerima interface dan memanggil method.

Kontrak Interface, Banyak Implementasi #

Inti data layer multiplatform adalah pemisahan kontrak dari implementasi. Kontrak TransaksiRepo hidup di commonMain dan ditandai interface. Setiap source set platform menyediakan implementasi konkret dengan teknologi yang sesuai:

PlatformSource setImplementasiLibrary
SemuacommonMainTransaksiRepoInMemoryKotlin stdlib saja
JVM/Desktop/ServerjvmMainTransaksiRepoJdbcJDBC + SQLite
AndroidandroidMainTransaksiRepoRoomRoom (Android Jetpack)
iOSiosMainTransaksiRepoSqlDelightSQLDelight

Aplikasi memilih implementasi saat startup (dependency injection) dan menyuntikkannya ke use case. Use case tidak tahu platform; behavior identik. Pola ini disebut strategy atau dependency inversion.

Implementasi Multiplatform Data Layer #

Kode berikut mendemonstrasikan TransaksiRepoInMemory (commonMain) dan TransaksiRepoJdbc (jvmMain) yang keduanya mengimplementasikan interface yang sama. Demo runner memakai keduanya dan membandingkan hasil yang identik:

kotlin
// Bagian 2: Multiplatform data layer.
// Mendemonstrasikan repository dengan beberapa implementasi per platform:
// In-memory (commonMain), JDBC (jvmMain), dan mock untuk iOS/Android.

import java.sql.Connection
import java.sql.DriverManager

// === commonMain: domain model ===
data class Transaksi(
    val id: Long,
    val jenis: String,   // "PEMASUKAN" atau "PENGELUARAN"
    val jumlah: Long,
    val kategori: String,
    val tanggalIso: String
)

// === commonMain: repository interface ===
interface TransaksiRepo {
    fun simpan(t: Transaksi): Transaksi
    fun semua(): List<Transaksi>
    fun cariByKategori(kategori: String): List<Transaksi>
    fun hapus(id: Long): Boolean
}

// === commonMain: in-memory implementation ===
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) }
    override fun hapus(id: Long): Boolean = daftar.removeIf { it.id == id }
}

// === jvmMain: JDBC implementation (pakai SQLite) ===
// Di KMP asli, class ini di source set jvmMain.
class TransaksiRepoJdbc(private val conn: Connection) : TransaksiRepo {
    init {
        conn.createStatement().use { stmt ->
            stmt.executeUpdate("""
                CREATE TABLE IF NOT EXISTS transaksi (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    jenis TEXT NOT NULL,
                    jumlah INTEGER NOT NULL,
                    kategori TEXT NOT NULL,
                    tanggal_iso TEXT NOT NULL
                )
            """.trimIndent())
            stmt.executeUpdate("CREATE INDEX IF NOT EXISTS idx_trans_kategori ON transaksi(kategori)")
        }
    }

    override fun simpan(t: Transaksi): Transaksi {
        conn.prepareStatement(
            "INSERT INTO transaksi (jenis, jumlah, kategori, tanggal_iso) VALUES (?, ?, ?, ?)",
            java.sql.Statement.RETURN_GENERATED_KEYS
        ).use { ps ->
            ps.setString(1, t.jenis)
            ps.setLong(2, t.jumlah)
            ps.setString(3, t.kategori)
            ps.setString(4, t.tanggalIso)
            ps.executeUpdate()
            ps.generatedKeys.use { rs ->
                if (rs.next()) return t.copy(id = rs.getLong(1))
            }
        }
        return t
    }

    override fun semua(): List<Transaksi> {
        val hasil = mutableListOf<Transaksi>()
        conn.prepareStatement("SELECT id, jenis, jumlah, kategori, tanggal_iso FROM transaksi ORDER BY id").use { ps ->
            ps.executeQuery().use { rs ->
                while (rs.next()) {
                    hasil.add(Transaksi(
                        rs.getLong("id"), rs.getString("jenis"),
                        rs.getLong("jumlah"), rs.getString("kategori"),
                        rs.getString("tanggal_iso")
                    ))
                }
            }
        }
        return hasil
    }

    override fun cariByKategori(kategori: String): List<Transaksi> {
        val hasil = mutableListOf<Transaksi>()
        conn.prepareStatement(
            "SELECT id, jenis, jumlah, kategori, tanggal_iso FROM transaksi WHERE kategori LIKE ? ORDER BY id"
        ).use { ps ->
            ps.setString(1, "%$kategori%")
            ps.executeQuery().use { rs ->
                while (rs.next()) {
                    hasil.add(Transaksi(
                        rs.getLong("id"), rs.getString("jenis"),
                        rs.getLong("jumlah"), rs.getString("kategori"),
                        rs.getString("tanggal_iso")
                    ))
                }
            }
        }
        return hasil
    }

    override fun hapus(id: Long): Boolean {
        conn.prepareStatement("DELETE FROM transaksi WHERE id = ?").use { ps ->
            ps.setLong(1, id)
            return ps.executeUpdate() > 0
        }
    }
}

// === commonMain: use case pakai interface ===
class BukuKeuangan(private val repo: TransaksiRepo) {
    fun catat(jenis: String, jumlah: Long, kategori: String, tanggalIso: String): Transaksi =
        repo.simpan(Transaksi(0, jenis, jumlah, kategori, tanggalIso))

    fun semua(): List<Transaksi> = repo.semua()
    fun byKategori(kategori: String): List<Transaksi> = repo.cariByKategori(kategori)
    fun hapus(id: Long): Boolean = repo.hapus(id)
}

fun demo(label: String, repo: TransaksiRepo) {
    println("--- $label ---")
    val buku = BukuKeuangan(repo)
    buku.catat("PEMASUKAN", 5_000_000, "Gaji", "2026-01-05")
    buku.catat("PENGELUARAN", 750_000, "Makan", "2026-01-08")
    buku.catat("PENGELUARAN", 1_200_000, "Transport", "2026-01-12")
    println("  Total: ${buku.semua().size} transaksi")
    buku.semua().forEach { t ->
        val arah = if (t.jenis == "PEMASUKAN") "+" else "-"
        println("    [${t.id}] $arah Rp${t.jumlah} ${t.kategori} (${t.tanggalIso})")
    }
    println("  Cari kategori 'makan':")
    buku.byKategori("makan").forEach { t ->
        println("    [${t.id}] Rp${t.jumlah} ${t.kategori}")
    }
    println("  Hapus id 1: ${buku.hapus(1)}")
    println("  Sisa: ${buku.semua().size} transaksi")
}

fun main() {
    println("=== Multiplatform Data Layer ===")
    println("commonMain: TransaksiRepo interface + InMemory")
    println("jvmMain   : TransaksiRepoJdbc (SQLite)")
    println("androidMain: TransaksiRepoRoom (Room)")
    println("iosMain   : TransaksiRepoSqlDelight")

    println()
    demo("In-memory (commonMain)", TransaksiRepoInMemory())

    println()
    // Pada JVM, JDBC tersedia. SQLite via JDBC driver.
    val conn = DriverManager.getConnection("jdbc:sqlite::memory:")
    demo("JDBC + SQLite (jvmMain)", TransaksiRepoJdbc(conn))
    conn.close()
}

Output yang Dihasilkan:

text
=== Multiplatform Data Layer ===
commonMain: TransaksiRepo interface + InMemory
jvmMain   : TransaksiRepoJdbc (SQLite)
androidMain: TransaksiRepoRoom (Room)
iosMain   : TransaksiRepoSqlDelight

--- In-memory (commonMain) ---
  Total: 3 transaksi
    [1] + Rp5000000 Gaji (2026-01-05)
    [2] - Rp750000 Makan (2026-01-08)
    [3] - Rp1200000 Transport (2026-01-12)
  Cari kategori 'makan':
    [2] Rp750000 Makan
  Hapus id 1: true
  Sisa: 2 transaksi

--- JDBC + SQLite (jvmMain) ---
  Total: 3 transaksi
    [1] + Rp5000000 Gaji (2026-01-05)
    [2] - Rp750000 Makan (2026-01-08)
    [3] - Rp1200000 Transport (2026-01-12)
  Cari kategori 'makan':
    [2] Rp750000 Makan
  Hapus id 1: true
  Sisa: 2 transaksi

Mari kita bedah komponen utama:

  1. interface TransaksiRepo di commonMain: kontrak dengan empat method. Interface ini berjalan di semua platform karena hanya memakai tipe Transaksi (data class multiplatform) dan List (stdlib multiplatform).
  2. class TransaksiRepoInMemory : TransaksiRepo: implementasi di commonMain yang memakai mutableListOf. Berguna untuk demo cepat dan unit test yang tidak butuh database nyata. Behavior-nya identik dengan implementasi database.
  3. class TransaksiRepoJdbc(conn: Connection): implementasi jvmMain yang memakai JDBC. Pakai PreparedStatement dengan parameter untuk mencegah SQL injection; RETURN_GENERATED_KEYS untuk mendapatkan ID auto-increment; use { ... } untuk auto-close resource.
  4. fun demo(label: String, repo: TransaksiRepo): helper yang menerima repo via interface dan menjalankan operasi yang sama. Memanggil demo dengan InMemory dan Jdbc menghasilkan output identik — bukti bahwa kontrak dihormati.
  5. TransaksiRepoRoom (Android) dan TransaksiRepoSqlDelight (iOS): tidak diimplementasikan dalam demo karena membutuhkan SDK Android dan iOS. Pola strukturnya sama: class RepoX : TransaksiRepo yang memanggil library spesifik platform.

Room di Android #

Di Android, TransaksiRepoRoom memakai Room. Skema tabel sama, tetapi Dao ditulis dengan annotation:

kotlin Room Dao di androidMain
@Dao
interface TransaksiDao {
    @Insert suspend fun insert(t: TransaksiEntity): Long
    @Query("SELECT * FROM transaksi ORDER BY id") fun semua(): Flow<List<TransaksiEntity>>
    @Query("SELECT * FROM transaksi WHERE kategori LIKE :k") fun byKategori(k: String): List<TransaksiEntity>
    @Delete suspend fun hapus(t: TransaksiEntity)
}

class TransaksiRepoRoom(private val dao: TransaksiDao) : TransaksiRepo {
    override suspend fun simpan(t: Transaksi): Transaksi {
        val id = dao.insert(t.toEntity())
        return t.copy(id = id)
    }
    // ...
}

Perbedaan dengan JDBC: Room memakai suspend (coroutines) dan Flow (observable), bukan List langsung. Use case BukuKeuangan perlu disesuaikan untuk memakai suspend jika ingin kompatibel dengan Room.

SQLDelight di iOS #

Di iOS, TransaksiRepoSqlDelight memakai SQLDelight yang menghasilkan kode Kotlin dari file .sq:

text Transaksi.sq di iosMain
insertTransaksi:
INSERT INTO transaksi (jenis, jumlah, kategori, tanggal_iso) VALUES (?, ?, ?, ?);

selectAll:
SELECT id, jenis, jumlah, kategori, tanggal_iso FROM transaksi ORDER BY id;

SQLDelight menghasilkan type-safe Kotlin function insertTransaksi dan selectAll. Implementasi TransaksiRepoSqlDelight memanggil function ini di atas TransaksiDatabase (driver Native SQLite).

Pola Dependency Injection #

Untuk memilih implementasi yang tepat per platform, gunakan factory function yang di-impl per platform:

kotlin Factory per platform
// commonMain
expect fun buatTransaksiRepo(): TransaksiRepo

// jvmMain
actual fun buatTransaksiRepo(): TransaksiRepo =
    TransaksiRepoJdbc(DriverManager.getConnection("jdbc:sqlite:app.db"))

// androidMain
actual fun buatTransaksiRepo(): TransaksiRepo =
    TransaksiRepoRoom(AppDatabase.getInstance(context).transaksiDao())

// iosMain
actual fun buatTransaksiRepo(): TransaksiRepo =
    TransaksiRepoSqlDelight(DatabaseDriverFactory().createDriver())

expect/actual akan dibahas lebih detail di Bagian 3. Pola ini membuat use case BukuKeuangan tidak tahu platform; factory function yang menentukan implementasi.

Referensi Lanjutan #

  • Kotlin Multiplatform — halaman utama KMP untuk memahami expect/actual pada lapisan data lintas platform

Ringkasan Bagian 2 #

Data layer multiplatform kini lengkap. Interface TransaksiRepo hidup di commonMain dengan implementasi InMemory untuk testing dan Jdbc untuk JVM. Pola yang sama dapat diterapkan untuk Room (Android) dan SQLDelight (iOS). Use case BukuKeuangan tidak tahu implementasi mana yang dipakai. Pada Bagian 3 kita menambahkan platform-specific integration via expect/actual untuk fitur yang tidak dapat diabstraksi.