Clean Architecture adalah pola yang diperkenalkan Robert C. Martin untuk memisahkan aturan bisnis dari detail infrastruktur. Pola ini menempatkan entity dan use case di pusat, dikelilingi oleh interface adapter dan framework terluar. Aturan dependency-nya ketat: code di ring luar boleh bergantung ke ring dalam, tetapi tidak sebaliknya. Pada chapter ini kita akan membangun aplikasi login dengan empat ring tersebut, lalu menelaah bagaimana domain entity tidak tahu tentang database atau HTTP.

Empat Ring dan Aturan Dependency #

Empat ring dari dalam ke luar:

RingIsiBoleleh mengacu ke
EntitiesAturan bisnis enterprise, paling stabilTidak ada
Use CasesLogika aplikasi-spesifikEntities + ports
Interface AdaptersKonversi antara use case dan UI/DBUse Cases
Frameworks & DriversDB, web framework, UI frameworkInterface Adapters

Ring dalam tidak boleh import apapun dari ring luar. Jika use case butuh akses database, ia mendefinisikan interface (port) di ring use case, lalu ring terluar menyediakan class konkret yang mengimplementasikan interface tersebut.

Implementasi Clean Architecture untuk Login #

kotlin
// Demo Clean Architecture: entities, use cases, interface adapters, frameworks.
// Aturan dependency: outer ring tergantung inner ring, tidak sebaliknya.

import java.time.LocalDate

// === ENTITIES (paling dalam): aturan bisnis inti ===
data class Pengguna(val id: Int, val nama: String, val email: String, val aktif: Boolean)
data class Sesi(val token: String, val penggunaId: Int, val kedaluwarsa: LocalDate)

class EntitasSesi {
    fun buatSesi(pengguna: Pengguna): Sesi {
        require(pengguna.aktif) { "Pengguna tidak aktif" }
        // Token deterministik untuk demo (di produksi: UUID atau JWT)
        val token = "sesi-" + pengguna.id + "-" + pengguna.nama.length
        return Sesi(token, pengguna.id, LocalDate.of(2026, 1, 16))
    }

    fun validasiSesi(sesi: Sesi, sekarang: LocalDate): Boolean {
        return !sesi.kedaluwarsa.isBefore(sekarang)
    }
}

// === USE CASES: aplikasi-spesifik; tergantung entity, tidak tergantung framework ===
class LoginUseCase(
    private val repoPengguna: PenggunaRepository,  // port (interface)
    private val repoSesi: SesiRepository           // port (interface)
) {
    fun execute(email: String): HasilLogin {
        val pengguna = repoPengguna.cariByEmail(email)
            ?: return HasilLogin.Gagal("Pengguna tidak ditemukan")
        if (!pengguna.aktif) return HasilLogin.Gagal("Pengguna nonaktif")
        val sesi = EntitasSesi().buatSesi(pengguna)
        repoSesi.simpan(sesi)
        return HasilLogin.Berhasil(sesi)
    }
}

sealed class HasilLogin {
    data class Berhasil(val sesi: Sesi) : HasilLogin()
    data class Gagal(val alasan: String) : HasilLogin()
}

// === PORTS (interfaces): didefinisikan di layer use case, diimplementasi di outer ===
interface PenggunaRepository { fun cariByEmail(email: String): Pengguna? }
interface SesiRepository { fun simpan(sesi: Sesi); fun ambil(token: String): Sesi? }

// === INTERFACE ADAPTERS: konversi antara use case dan presentation ===
class LoginPresenter {
    fun tampilkan(hasil: HasilLogin): String = when (hasil) {
        is HasilLogin.Berhasil -> "Login berhasil. Token: ${hasil.sesi.token}"
        is HasilLogin.Gagal -> "Login gagal: ${hasil.alasan}"
    }
}

// === FRAMEWORKS & DRIVERS (paling luar): DB, web, dll ===
class InMemoryPenggunaRepository(private val data: Map<String, Pengguna>) : PenggunaRepository {
    override fun cariByEmail(email: String) = data[email]
}

class InMemorySesiRepository : SesiRepository {
    private val sesi = mutableMapOf<String, Sesi>()
    override fun simpan(sesi: Sesi) { this.sesi[sesi.token] = sesi }
    override fun ambil(token: String) = sesi[token]
}

// === Main: entry point dari framework (console app) ===
fun main() {
    println("=== Demo Clean Architecture ===")

    val repoPengguna = InMemoryPenggunaRepository(mapOf(
        "[email protected]" to Pengguna(1, "Budi", "[email protected]", true),
        "[email protected]" to Pengguna(2, "Siti", "[email protected]", false)
    ))
    val repoSesi = InMemorySesiRepository()
    val useCase = LoginUseCase(repoPengguna, repoSesi)
    val presenter = LoginPresenter()

    println("--- Skenario: pengguna aktif ---")
    val h1 = useCase.execute("[email protected]")
    println(presenter.tampilkan(h1))

    println()
    println("--- Skenario: pengguna nonaktif ---")
    val h2 = useCase.execute("[email protected]")
    println(presenter.tampilkan(h2))

    println()
    println("--- Skenario: email tidak terdaftar ---")
    val h3 = useCase.execute("[email protected]")
    println(presenter.tampilkan(h3))
}

Output yang Dihasilkan:

text
=== Demo Clean Architecture ===
--- Skenario: pengguna aktif ---
Login berhasil. Token: sesi-1-4

--- Skenario: pengguna nonaktif ---
Login gagal: Pengguna nonaktif

--- Skenario: email tidak terdaftar ---
Login gagal: Pengguna tidak ditemukan

Bedah Kode Baris-per-Baris #

Mari kita telusuri arah dependensi:

  1. Baris 8-21 (Entities): Pengguna, Sesi, dan EntitasSesi tidak mengimpor apapun dari luar. Aturan “pengguna aktif boleh login” ada di sini, bukan di use case atau controller.
  2. Baris 24-36 (LoginUseCase): Use case menerima dua port sebagai dependensi. Ia memanggil repoPengguna.cariByEmail dan repoSesi.simpan tanpa peduli implementasi konkret.
  3. Baris 38-41 (sealed class HasilLogin): Hasil use case direpresentasikan sebagai sealed class. Presenter dapat membedakan Berhasil dan Gagal secara exhaustive di when.
  4. Baris 44-45 (ports): PenggunaRepository dan SesiRepository dideklarasikan di layer use case. Inilah titik pembalikan dependensi: use case memiliki interface, infrastructure yang mengimplementasinya.
  5. Baris 56-64 (frameworks): InMemoryPenggunaRepository dan InMemorySesiRepository adalah adapter konkret. Untuk produksi, kita bisa membuat JdbcPenggunaRepository tanpa mengubah kode use case.

Trade-off Clean Architecture #

Clean Architecture membeli testability dan fleksibilitas dengan biaya boilerplate. Use case sederhana seperti LoginUseCase menjadi 30+ baris dengan port, presenter, dan adapter yang bisa saja ditulis dalam 5 baris di script monolitik. Trade-off ini sepadan ketika domain kompleks dan kode akan hidup bertahun-tahun; tidak sepadan untuk prototype sekali pakai.

Penutup #

Clean Architecture menempatkan aturan bisnis di pusat dan detail infrastruktur di pinggiran, dengan dependency rule yang ketat mengarah ke dalam. Pembaca kini punya tiga pilihan pola: layered, clean, dan (selanjutnya) hexagonal. Pada chapter berikutnya kita akan membahas Hexagonal Architecture: pendekatan ports and adapters yang lebih ekspresif dalam mengelola driving dan driven side.

Referensi #

Referensi Lanjutan #

  • Kotlin documentation home — halaman utama dokumentasi Kotlin untuk interface dan internal yang memisahkan core dari detail infrastruktur