Hexagonal architecture meletakkan domain di pusat; Robert C. Martin merapikan gagasan serupa menjadi Clean Architecture — lingkaran konsentris dengan satu aturan yang disebutnya The Dependency Rule: kode sumber hanya boleh bergantung ke lingkar yang lebih dalam. Framework, database, dan antarmuka berada di lingkar terluar; entity dan use case di dalam. Aturan satu kalimat ini menjawab kekacauan yang muncul ketika aplikasi besar memakai belasan framework: semua detail boleh hadir, tetapi tidak satu pun boleh menembus ke domain. Chapter ini membangun program parkiran mini yang memetakan empat lingkar tersebut, lalu membandingkannya dengan hexagonal dan onion.

Aturan Dependensi Melingkar #

Empat lingkar versi Martin dari dalam ke luar: Entities (aturan bisnis paling umum), Use Cases (alur spesifik aplikasi), Interface Adapters (penerjemah antara use case dan dunia luar), serta Frameworks & Drivers (web framework, database, UI). Kode di lingkar luar boleh mengimpor lingkar dalam; sebaliknya dilarang keras — lingkar dalam tidak boleh menyebut nama Spring, JDBC, atau HTTP.

Bila use case membutuhkan database, use case mendeklarasikan interface (port) di lingkarannya sendiri, lalu lingkar luar yang mengimplementasikannya — mekanisme dependency inversion yang sudah kita kenal, kini dipromosikan menjadi hukum arsitektur. Kontrol alur tetap boleh dari luar ke dalam (user memicu use case), tetapi kode sumber — pernyataan import dan referensi tipe — hanya mengalir ke dalam.

Empat Lingkar dalam Satu Program #

Program parkiran berikut menempatkan setiap class pada lingkarnya: Karcis sebagai entity, LayananParkir beserta port PenyimpanKarcis sebagai use case, PengelolaParkiran dan PenyimpanKarcisMemori sebagai interface adapter, serta main sebagai lingkar framework:

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;

public class AturanDependensi {
    public static void main(String[] args) {
        PenyimpanKarcisMemori penyimpan = new PenyimpanKarcisMemori();
        LayananParkir useCase = new LayananParkir(penyimpan);
        PengelolaParkiran controller = new PengelolaParkiran(useCase);

        controller.kendaraanMasuk("B 1234 CD", 8);
        controller.kendaraanMasuk("B 5678 EF", 14);
        controller.kendaraanKeluar("B 1234 CD", 11);
        controller.kendaraanKeluar("B 5678 EF", 15);
        controller.kendaraanKeluar("B 9999 ZZ", 16); // plat tidak terdaftar
    }
}

// ===== Lingkar dalam: Entities =====
record Karcis(String plat, int jamMasuk) {
}

// ===== Lingkar kedua: Use cases (port milik use case) =====
interface PenyimpanKarcis {
    void simpan(Karcis karcis);

    Optional<Karcis> cari(String plat);

    void hapus(String plat);
}

class LayananParkir {
    private final PenyimpanKarcis penyimpan;

    LayananParkir(PenyimpanKarcis penyimpan) {
        this.penyimpan = penyimpan;
    }

    String masuk(String plat, int jamMasuk) {
        if (penyimpan.cari(plat).isPresent()) {
            return "Ditolak: " + plat + " sudah berada di dalam";
        }
        penyimpan.simpan(new Karcis(plat, jamMasuk));
        return "Masuk: " + plat + " pukul " + jamMasuk + ".00";
    }

    String keluar(String plat, int jamKeluar) {
        Optional<Karcis> hasil = penyimpan.cari(plat);
        if (hasil.isEmpty()) {
            return "Keluar gagal: " + plat + " tidak terdaftar";
        }
        Karcis karcis = hasil.get();
        int durasi = Math.max(jamKeluar - karcis.jamMasuk(), 1);
        long biaya = durasi * 3_000L;
        penyimpan.hapus(plat);
        return "Keluar: " + plat + ", " + durasi + " jam, Rp" + biaya;
    }
}

// ===== Lingkar ketiga: Interface adapters =====
class PengelolaParkiran {
    private final LayananParkir useCase;

    PengelolaParkiran(LayananParkir useCase) {
        this.useCase = useCase;
    }

    void kendaraanMasuk(String plat, int jam) {
        System.out.println(useCase.masuk(plat, jam));
    }

    void kendaraanKeluar(String plat, int jam) {
        System.out.println(useCase.keluar(plat, jam));
    }
}

class PenyimpanKarcisMemori implements PenyimpanKarcis {
    private final Map<String, Karcis> aktif = new LinkedHashMap<>();

    public void simpan(Karcis karcis) {
        aktif.put(karcis.plat(), karcis);
    }

    public Optional<Karcis> cari(String plat) {
        return Optional.ofNullable(aktif.get(plat));
    }

    public void hapus(String plat) {
        aktif.remove(plat);
    }
}

Output yang Dihasilkan:

text
Masuk: B 1234 CD pukul 8.00
Masuk: B 5678 EF pukul 14.00
Keluar: B 1234 CD, 3 jam, Rp9000
Keluar: B 5678 EF, 1 jam, Rp3000
Keluar gagal: B 9999 ZZ tidak terdaftar

Mari kita bedah penempatan tiap class pada lingkarnya:

  1. Baris 20-21 (record Karcis): entity di lingkar terdalam — data murni tanpa referensi teknologi apa pun. Mengganti database, web framework, maupun konsol tidak akan menyentuh baris ini.
  2. Baris 24-30 (interface PenyimpanKarcis): port dideklarasikan di lingkar use case, bukan di lingkar adapter. LayananParkir (baris 32-57) hanya mengenal interface ini — hukum dependensi terpenuhi secara mekanis karena compiler menolak import dari lingkar luar ke dalam.
  3. Baris 61-75 (PengelolaParkiran): adapter masuk menerjemahkan bahasa luar (permintaan operator) menjadi pemanggilan use case. Di aplikasi web, class ini digantikan REST controller; use case tidak berubah.
  4. Baris 77-91 (PenyimpanKarcisMemori): adapter keluar mengimplementasikan port dengan LinkedHashMap. Versi JDBC-nya akan menyimpan ke tabel — perubahan tetap terkurung di lingkar ketiga.
  5. Baris 7-9 (main sebagai perakit): lingkar terluar sekaligus titik perakitan. main satu-satunya tempat yang boleh menyebut semua class sekaligus, persis seperti composition root pada chapter Dependency Injection.

Perbandingan dengan Hexagonal dan Onion #

Ketiga arsitektur ini sering dianggap satu keluarga karena semuanya menuntut domain bebas detail. Perbedaannya pada penekanan:

AspekHexagonalCleanOnion
MetaforaPort dan adapter di sisi heksagonLingkaran konsentris dengan aturan dependensiLapisan bawang; domain service di pusat
PenekananSimetri port masuk/keluarAturan dependensi eksplisit dan arah kontrolModel domain + service; aplikasi membungkusnya
PenulisCockburn (2005)Martin (2012)Palermo (2008)

Dalam praktik, tim sering memadukan: struktur package heksagonal, aturan dependensi clean, dan penataan domain ala onion. Yang tidak bisa dikompromikan adalah satu hal: domain tidak mengimpor framework.

Kritik Boilerplate #

Kritik paling sering dilontarkan: clean architecture menumbuhkan boilerplate. Satu fitur “keluar parkiran” menjadi lima class plus satu interface plus DTO untuk melintasui lingkar; presenter terpisah dari view; use case dipecah per aksi. Untuk aplikasi kecil, biaya ritual ini melampaui manfaatnya. Martin sendiri menjawab dengan trade-off yang jujur: arsitektur ini membeli kemandirian domain dengan membayar file tambahan — keputusan yang masuk akal saat aplikasi berumur panjang dan framework berganti-ganti, dan berlebihan saat proyek hidup tiga bulan.

Ketiga arsitektur ini menjawab bagaimana menyusun kode; pertanyaan berikutnya lebih dalam: bahasa apa yang dipakai kode itu — istilah siapa yang menentukan nama class dan method? Chapter selanjutnya masuk ke wilayah Eric Evans: Domain-Driven Design.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

AturanDependensi.java

Unduh berkas
java
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;

public class AturanDependensi {
    public static void main(String[] args) {
        PenyimpanKarcisMemori penyimpan = new PenyimpanKarcisMemori();
        LayananParkir useCase = new LayananParkir(penyimpan);
        PengelolaParkiran controller = new PengelolaParkiran(useCase);

        controller.kendaraanMasuk("B 1234 CD", 8);
        controller.kendaraanMasuk("B 5678 EF", 14);
        controller.kendaraanKeluar("B 1234 CD", 11);
        controller.kendaraanKeluar("B 5678 EF", 15);
        controller.kendaraanKeluar("B 9999 ZZ", 16); // plat tidak terdaftar
    }
}

// ===== Lingkar dalam: Entities =====
record Karcis(String plat, int jamMasuk) {
}

// ===== Lingkar kedua: Use cases (port milik use case) =====
interface PenyimpanKarcis {
    void simpan(Karcis karcis);

    Optional<Karcis> cari(String plat);

    void hapus(String plat);
}

class LayananParkir {
    private final PenyimpanKarcis penyimpan;

    LayananParkir(PenyimpanKarcis penyimpan) {
        this.penyimpan = penyimpan;
    }

    String masuk(String plat, int jamMasuk) {
        if (penyimpan.cari(plat).isPresent()) {
            return "Ditolak: " + plat + " sudah berada di dalam";
        }
        penyimpan.simpan(new Karcis(plat, jamMasuk));
        return "Masuk: " + plat + " pukul " + jamMasuk + ".00";
    }

    String keluar(String plat, int jamKeluar) {
        Optional<Karcis> hasil = penyimpan.cari(plat);
        if (hasil.isEmpty()) {
            return "Keluar gagal: " + plat + " tidak terdaftar";
        }
        Karcis karcis = hasil.get();
        int durasi = Math.max(jamKeluar - karcis.jamMasuk(), 1);
        long biaya = durasi * 3_000L;
        penyimpan.hapus(plat);
        return "Keluar: " + plat + ", " + durasi + " jam, Rp" + biaya;
    }
}

// ===== Lingkar ketiga: Interface adapters =====
class PengelolaParkiran {
    private final LayananParkir useCase;

    PengelolaParkiran(LayananParkir useCase) {
        this.useCase = useCase;
    }

    void kendaraanMasuk(String plat, int jam) {
        System.out.println(useCase.masuk(plat, jam));
    }

    void kendaraanKeluar(String plat, int jam) {
        System.out.println(useCase.keluar(plat, jam));
    }
}

class PenyimpanKarcisMemori implements PenyimpanKarcis {
    private final Map<String, Karcis> aktif = new LinkedHashMap<>();

    public void simpan(Karcis karcis) {
        aktif.put(karcis.plat(), karcis);
    }

    public Optional<Karcis> cari(String plat) {
        return Optional.ofNullable(aktif.get(plat));
    }

    public void hapus(String plat) {
        aktif.remove(plat);
    }
}