Kode yang tumbuh tanpa disiplin desain biasanya berujung pada satu class raksasa yang mengurus segalanya: membaca masukan, menghitung angka, menyimpan data, sekaligus mencetak hasil. Setiap fitur baru pun menambah risiko regresi di tempat yang tidak berkaitan. Lima prinsip SOLID — dirumuskan oleh Robert C. Martin — menjadi rujukan paling berpengaruh untuk mencegah kondisi tersebut. Setiap huruf menjawab satu pertanyaan praktis: kapan memecah class, bagaimana menambah fitur tanpa menyentuh kode lama, dan ke arah mana dependensi boleh mengalir. Chapter ini membedah kelimanya dengan contoh Java yang dapat dijalankan.

Single Responsibility Principle #

Prinsip S menyatakan: satu class hanya boleh memiliki satu alasan untuk berubah. Alasan berubah berarti satu pihak atau satu kebutuhan — bukan sekadar satu method. Bila class yang sama menangani perhitungan statistik dan sekaligus format tampilan, permintaan perubahan tampilan akan menyentuh kode perhitungan, dan sebaliknya. Bentuk awal yang umum ditemui tampak seperti berikut:

java
// Sebelum: satu class menanggung tiga tanggung jawab sekaligus
class LaporanRapor {
    String tampilkan(List<Integer> nilai) {
        double rata = 0;
        for (int n : nilai) { rata += n; }
        rata /= nilai.size();
        int max = 0;
        for (int n : nilai) { if (n > max) { max = n; } }
        return String.format("Rata-rata: %.1f%nNilai tertinggi: %d", rata, max);
    }
}

Method di atas menghitung rata-rata, mencari nilai tertinggi, lalu memformat teks — tiga tanggung jawab dalam satu tubuh. Kita pecah menjadi dua class dengan satu tanggung jawab masing-masing:

import java.util.List;

public class RaporTerpisah {
    public static void main(String[] args) {
        List<Integer> nilai = List.of(78, 85, 90, 66, 74);
        StatistikNilai statistik = new StatistikNilai(nilai);
        PapanRapor papan = new PapanRapor(statistik);
        System.out.println(papan.tampilkan());
    }
}

// Tanggung jawab 1: menghitung statistik nilai
class StatistikNilai {
    private final List<Integer> nilai;

    StatistikNilai(List<Integer> nilai) {
        this.nilai = List.copyOf(nilai);
    }

    double rataRata() {
        return nilai.stream().mapToInt(Integer::intValue).average().orElse(0);
    }

    int tertinggi() {
        return nilai.stream().mapToInt(Integer::intValue).max().orElse(0);
    }
}

// Tanggung jawab 2: merangkai tampilan teks
class PapanRapor {
    private final StatistikNilai statistik;

    PapanRapor(StatistikNilai statistik) {
        this.statistik = statistik;
    }

    String tampilkan() {
        return String.format("Rata-rata: %.1f%nNilai tertinggi: %d",
                statistik.rataRata(), statistik.tertinggi());
    }
}

Output yang Dihasilkan:

text
Rata-rata: 78.6
Nilai tertinggi: 90

Mari kita bedah hasil pemisahan tersebut:

  1. Baris 5-7 (new StatistikNilai(...), new PapanRapor(...)): method main menjadi tempat perakitan — ia hanya menyambungkan komponen, tidak menjalankan logika domain. Inilah ciri kode yang tanggung jawabnya sudah terpisah.
  2. Baris 17 (List.copyOf(nilai)): constructor membuat salinan internal sehingga perubahan list dari luar tidak mengganggu perhitungan. Statistik hanya peduli data, tidak peduli siapa pemiliknya.
  3. Baris 20-34 (rataRata(), tertinggi(), tampilkan()): kedua method hitung murni menghitung tanpa mencetak; seluruh keputusan format terkumpul di PapanRapor. Mengganti tampilan menjadi JSON kini tidak mengharuskan kita membuka class StatistikNilai.

Open Closed Principle #

Prinsip O menyatakan: class terbuka untuk perluasan, tertutup untuk modifikasi. Menambah jenis pembayaran seharusnya tidak menuntut penyuntingan kode yang sudah teruji. Bentuk yang melanggar prinsip ini biasanya berupa switch yang harus diedit setiap kali jenis baru masuk:

java
// Bau kode: setiap jenis baru memaksa edit method ini
long hitung(String jenis, long jumlah) {
    return switch (jenis) {
        case "tunai" -> jumlah;
        case "kartu" -> Math.round(jumlah * 1.02);
        default -> throw new IllegalArgumentException("Jenis tidak dikenal");
    };
}

Solusinya adalah polymorphism lewat interface. Program berikut menambahkan DompetDigital hanya dengan menulis class baru — kode Tunai, KartuKredit, dan loop di main tidak tersentuh sama sekali:

import java.util.List;

public class PembayaranPolimorfik {
    public static void main(String[] args) {
        List<Pembayaran> tagihan = List.of(
                new Tunai(150_000),
                new KartuKredit(320_000),
                new DompetDigital(75_000)); // jenis baru tanpa mengubah kode lama

        long total = 0;
        for (Pembayaran p : tagihan) {
            total += p.biaya();
            System.out.println(p.keterangan() + " Rp" + p.biaya());
        }
        System.out.println("Total: Rp" + total);
    }
}

interface Pembayaran {
    long biaya();

    String keterangan();
}

class Tunai implements Pembayaran {
    private final long jumlah;

    Tunai(long jumlah) {
        this.jumlah = jumlah;
    }

    public long biaya() {
        return jumlah;
    }

    public String keterangan() {
        return "Tunai         :";
    }
}

class KartuKredit implements Pembayaran {
    private final long jumlah;

    KartuKredit(long jumlah) {
        this.jumlah = jumlah;
    }

    public long biaya() {
        return Math.round(jumlah * 1.02); // biaya layanan 2%
    }

    public String keterangan() {
        return "Kartu kredit  :";
    }
}

class DompetDigital implements Pembayaran {
    private final long jumlah;

    DompetDigital(long jumlah) {
        this.jumlah = jumlah;
    }

    public long biaya() {
        return jumlah + 1_000; // biaya tetap Rp1.000
    }

    public String keterangan() {
        return "Dompet digital:";
    }
}

Output yang Dihasilkan:

text
Tunai         : Rp150000
Kartu kredit  : Rp326400
Dompet digital: Rp76000
Total: Rp552400

Perhatikan tiga hal dari struktur program tersebut:

  1. Baris 19 (interface Pembayaran): kontrak dua method ini menjadi satu-satunya hal yang diketahui loop di main. JVM mengikat pemanggilan p.biaya() secara dinamis ke implementasi asli setiap objek.
  2. Baris 6-8 (tiga new ...): jenis baru masuk hanya lewat baris perakitan — tidak ada switch atau if-else berantai yang perlu diedit.
  3. Baris 61-63 (class DompetDigital implements Pembayaran): satu class baru membawa seluruh logika jenisnya sendiri, sehingga uji ulang hanya dibutuhkan untuk class ini, bukan untuk seluruh mesin perhitungan.

Liskov Substitution Principle #

Prinsip L menyatakan: objek subtype harus dapat menggantikan objek supertype tanpa merusak perilaku program. Secara formal, subtype tidak boleh memperketat prasyarat (precondition) dan tidak boleh memperlonggar jaminan (postcondition) milik supertype. Pelanggaran klasiknya adalah relasi Persegi extends PersegiPanjang: secara konsep persegi memang persegi panjang, tetapi method setTinggi pada PersegiPanjang menjanjikan “hanya tinggi yang berubah”. Program berikut membuktikan masalahnya:

public class PersegiLiskov {
    public static void main(String[] args) {
        PersegiPanjang bidang = new Persegi(); // subtype menggantikan supertype
        bidang.setLebar(4);
        bidang.setTinggi(5);
        System.out.println("Luas         : " + bidang.luas());
        System.out.println("Diharapkan   : " + (4 * 5));
    }
}

class PersegiPanjang {
    protected int lebar;
    protected int tinggi;

    void setLebar(int lebar) {
        this.lebar = lebar;
    }

    void setTinggi(int tinggi) {
        this.tinggi = tinggi;
    }

    int luas() {
        return lebar * tinggi;
    }
}

class Persegi extends PersegiPanjang {
    @Override
    void setLebar(int lebar) {
        this.lebar = lebar;
        this.tinggi = lebar; // menjaga sisi tetap sama
    }

    @Override
    void setTinggi(int tinggi) {
        this.lebar = tinggi;
        this.tinggi = tinggi;
    }
}

Output yang Dihasilkan:

text
Luas         : 25
Diharapkan   : 20

Mari kita amati sumber ketidaksesuaian tersebut:

  1. Baris 4 (PersegiPanjang bidang = new Persegi()): baris ini sah menurut compiler — subtype boleh diisikan ke variabel supertype. Masalah baru muncul saat method dijalankan, bukan saat kompilasi.
  2. Baris 5 (bidang.setLebar(4)): override setLebar di Persegi turut mengubah tinggi demi menjaga invarian “keempat sisi sama” — melanggar jaminan setLebar milik PersegiPanjang yang hanya mengubah lebar.
  3. Baris 6 (bidang.setTinggi(5)): override ini memperketat prasyarat. Pemanggil yang mengikuti kontrak supertype berharap lebar tetap 4 dan luas 20, tetapi hasil tercetak 25.

Interface Segregation Principle #

Prinsip I menyatakan: klien tidak boleh dipaksa bergantung pada method yang tidak digunakannya. Interface gemuk memaksa implementasi penuh method kosong yang hanya melempar UnsupportedOperationException. Bentuk awalnya biasa tampak seperti ini:

java
// Antarmuka gemuk: printer sederhana terpaksa mengurus faks
interface MesinKantor {
    String cetak(String dokumen);
    String pindai(String dokumen);
    String faks(String dokumen);
}

Kita pecah menjadi dua interface kecil, lalu setiap class memilih hanya kemampuan yang benar-benar dimiliki:

public class AntarmukaTerpisah {
    public static void main(String[] args) {
        antreCetak(new PrinterSederhana());
        antreCetak(new MesinFotokopi());
    }

    // Klien hanya membutuhkan kemampuan mencetak
    static void antreCetak(Pencetak mesin) {
        System.out.println(mesin.cetak("Laporan bulanan"));
    }
}

interface Pencetak {
    String cetak(String dokumen);
}

interface Pemindai {
    String pindai(String dokumen);
}

class PrinterSederhana implements Pencetak {
    public String cetak(String dokumen) {
        return "Mencetak: " + dokumen;
    }
}

class MesinFotokopi implements Pencetak, Pemindai {
    public String cetak(String dokumen) {
        return "Menyalin: " + dokumen;
    }

    public String pindai(String dokumen) {
        return "Memindai: " + dokumen;
    }
}

Output yang Dihasilkan:

text
Mencetak: Laporan bulanan
Menyalin: Laporan bulanan

Dua hal patut dicermati dari pemecahan tersebut:

  1. Baris 8-10 (static void antreCetak(Pencetak mesin)): method antrean bertipe Pencetak, bukan MesinKantor. Kebutuhannya sempit, kontraknya pun sempit.
  2. Baris 29 (class MesinFotokopi implements Pencetak, Pemindai): class serbaguna boleh menggabungkan beberapa interface kecil. Penambahan kemampuan baru tidak mengganggu class lain.

Dependency Inversion Principle #

Prinsip D menyatakan: modul tingkat tinggi tidak boleh bergantung pada modul tingkat rendah — keduanya bergantung pada abstraksi. Tanpa prinsip ini, LayananPesanan langsung memanggil PengirimEmail konkret, sehingga mengganti kanal pengiriman berarti membongkar layanan. Arah dependensi yang sehat tampak seperti ini:

Panah dari LayananPesanan berhenti di interface PengirimPesan; class infrastruktur justru yang menyesuaikan diri mengikuti kontrak domain. Prinsip ini berdampak paling besar pada arsitektur aplikasi secara keseluruhan, sehingga dua chapter berikutnya membahasnya khusus — mulai dari Dependency Inversion hingga teknik penyambungannya, Dependency Injection.

Lima prinsip di atas bekerja paling baik saat setiap kekhawatiran spesifik sudah terpisah satu sama lain: presentasi, logika bisnis, dan persistensi. Pemisahan itulah topik chapter berikutnya, Separation of Concerns, sebelum kita melangkah ke inversi dependensi.

Referensi #

Berkas Sumber

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

AntarmukaTerpisah.java

Unduh berkas
java
public class AntarmukaTerpisah {
    public static void main(String[] args) {
        antreCetak(new PrinterSederhana());
        antreCetak(new MesinFotokopi());
    }

    // Klien hanya membutuhkan kemampuan mencetak
    static void antreCetak(Pencetak mesin) {
        System.out.println(mesin.cetak("Laporan bulanan"));
    }
}

interface Pencetak {
    String cetak(String dokumen);
}

interface Pemindai {
    String pindai(String dokumen);
}

class PrinterSederhana implements Pencetak {
    public String cetak(String dokumen) {
        return "Mencetak: " + dokumen;
    }
}

class MesinFotokopi implements Pencetak, Pemindai {
    public String cetak(String dokumen) {
        return "Menyalin: " + dokumen;
    }

    public String pindai(String dokumen) {
        return "Memindai: " + dokumen;
    }
}

PembayaranPolimorfik.java

Unduh berkas
java
import java.util.List;

public class PembayaranPolimorfik {
    public static void main(String[] args) {
        List<Pembayaran> tagihan = List.of(
                new Tunai(150_000),
                new KartuKredit(320_000),
                new DompetDigital(75_000)); // jenis baru tanpa mengubah kode lama

        long total = 0;
        for (Pembayaran p : tagihan) {
            total += p.biaya();
            System.out.println(p.keterangan() + " Rp" + p.biaya());
        }
        System.out.println("Total: Rp" + total);
    }
}

interface Pembayaran {
    long biaya();

    String keterangan();
}

class Tunai implements Pembayaran {
    private final long jumlah;

    Tunai(long jumlah) {
        this.jumlah = jumlah;
    }

    public long biaya() {
        return jumlah;
    }

    public String keterangan() {
        return "Tunai         :";
    }
}

class KartuKredit implements Pembayaran {
    private final long jumlah;

    KartuKredit(long jumlah) {
        this.jumlah = jumlah;
    }

    public long biaya() {
        return Math.round(jumlah * 1.02); // biaya layanan 2%
    }

    public String keterangan() {
        return "Kartu kredit  :";
    }
}

class DompetDigital implements Pembayaran {
    private final long jumlah;

    DompetDigital(long jumlah) {
        this.jumlah = jumlah;
    }

    public long biaya() {
        return jumlah + 1_000; // biaya tetap Rp1.000
    }

    public String keterangan() {
        return "Dompet digital:";
    }
}

PersegiLiskov.java

Unduh berkas
java
public class PersegiLiskov {
    public static void main(String[] args) {
        PersegiPanjang bidang = new Persegi(); // subtype menggantikan supertype
        bidang.setLebar(4);
        bidang.setTinggi(5);
        System.out.println("Luas         : " + bidang.luas());
        System.out.println("Diharapkan   : " + (4 * 5));
    }
}

class PersegiPanjang {
    protected int lebar;
    protected int tinggi;

    void setLebar(int lebar) {
        this.lebar = lebar;
    }

    void setTinggi(int tinggi) {
        this.tinggi = tinggi;
    }

    int luas() {
        return lebar * tinggi;
    }
}

class Persegi extends PersegiPanjang {
    @Override
    void setLebar(int lebar) {
        this.lebar = lebar;
        this.tinggi = lebar; // menjaga sisi tetap sama
    }

    @Override
    void setTinggi(int tinggi) {
        this.lebar = tinggi;
        this.tinggi = tinggi;
    }
}

RaporTerpisah.java

Unduh berkas
java
import java.util.List;

public class RaporTerpisah {
    public static void main(String[] args) {
        List<Integer> nilai = List.of(78, 85, 90, 66, 74);
        StatistikNilai statistik = new StatistikNilai(nilai);
        PapanRapor papan = new PapanRapor(statistik);
        System.out.println(papan.tampilkan());
    }
}

// Tanggung jawab 1: menghitung statistik nilai
class StatistikNilai {
    private final List<Integer> nilai;

    StatistikNilai(List<Integer> nilai) {
        this.nilai = List.copyOf(nilai);
    }

    double rataRata() {
        return nilai.stream().mapToInt(Integer::intValue).average().orElse(0);
    }

    int tertinggi() {
        return nilai.stream().mapToInt(Integer::intValue).max().orElse(0);
    }
}

// Tanggung jawab 2: merangkai tampilan teks
class PapanRapor {
    private final StatistikNilai statistik;

    PapanRapor(StatistikNilai statistik) {
        this.statistik = statistik;
    }

    String tampilkan() {
        return String.format("Rata-rata: %.1f%nNilai tertinggi: %d",
                statistik.rataRata(), statistik.tertinggi());
    }
}