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:
// 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:
Rata-rata: 78.6
Nilai tertinggi: 90Mari kita bedah hasil pemisahan tersebut:
- Baris 5-7 (
new StatistikNilai(...),new PapanRapor(...)): methodmainmenjadi tempat perakitan — ia hanya menyambungkan komponen, tidak menjalankan logika domain. Inilah ciri kode yang tanggung jawabnya sudah terpisah. - 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. - Baris 20-34 (
rataRata(),tertinggi(),tampilkan()): kedua method hitung murni menghitung tanpa mencetak; seluruh keputusan format terkumpul diPapanRapor. Mengganti tampilan menjadi JSON kini tidak mengharuskan kita membuka classStatistikNilai.
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:
// 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:
Tunai : Rp150000
Kartu kredit : Rp326400
Dompet digital: Rp76000
Total: Rp552400Perhatikan tiga hal dari struktur program tersebut:
- Baris 19 (
interface Pembayaran): kontrak dua method ini menjadi satu-satunya hal yang diketahui loop dimain. JVM mengikat pemanggilanp.biaya()secara dinamis ke implementasi asli setiap objek. - Baris 6-8 (tiga
new ...): jenis baru masuk hanya lewat baris perakitan — tidak adaswitchatauif-elseberantai yang perlu diedit. - 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:
Luas : 25
Diharapkan : 20Mari kita amati sumber ketidaksesuaian tersebut:
- 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. - Baris 5 (
bidang.setLebar(4)): overridesetLebardiPersegiturut mengubahtinggidemi menjaga invarian “keempat sisi sama” — melanggar jaminansetLebarmilikPersegiPanjangyang hanya mengubah lebar. - Baris 6 (
bidang.setTinggi(5)): override ini memperketat prasyarat. Pemanggil yang mengikuti kontrak supertype berharap lebar tetap4dan luas20, tetapi hasil tercetak25.
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:
// 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:
Mencetak: Laporan bulanan
Menyalin: Laporan bulananDua hal patut dicermati dari pemecahan tersebut:
- Baris 8-10 (
static void antreCetak(Pencetak mesin)): method antrean bertipePencetak, bukanMesinKantor. Kebutuhannya sempit, kontraknya pun sempit. - 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 berkaspublic 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 berkasimport 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 berkaspublic 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 berkasimport 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());
}
}