Semua kode bisa berjalan; yang membedakan desain baik dan buruk adalah biaya perubahan. Desain yang buruk membuat fitur kecil menuntut penyentuhan puluhan file, sedangkan desain yang baik menyediakan tempat yang jelas untuk kode baru. Prinsip desain — SOLID hingga KISS — adalah himpunan pedoman yang dirangkum dari pengalaman puluhan tahun rekayasa perangkat lunak, bukan hukum alam. Kita akan membedahnya satu per satu dengan contoh Python yang bisa dijalankan, lalu menutup dengan dua ukuran yang mendasari semuanya: kohesi dan kopling.

SOLID dalam Python #

SOLID adalah akronim lima prinsip desain class. Di Python yang serba dinamis, kelima prinsip itu tidak selalu tampil dalam bentuk textbook — wujudnya menyesuaikan duck typing dan Protocol.

S — Satu Alasan untuk Berubah #

Sebuah class semestinya punya satu tanggung jawab. Bayangkan class yang sekaligus memformat laporan dan menulisnya ke file: perubahan tampilan dan perubahan media penyimpanan sama-sama memaksa class itu diedit. Setelah dipisah:

python
class Laporan:
    """Hanya menyimpan dan memformat data laporan."""

    def __init__(self, judul, baris):
        self.judul = judul
        self.baris = baris

    def ke_teks(self):
        return f"{self.judul}: {', '.join(self.baris)}"


class PenyimpanLaporan:
    """Hanya tahu cara menyimpan laporan ke disk."""

    def simpan(self, laporan, path):
        with open(path, "w") as berkas:
            berkas.write(laporan.ke_teks())


laporan = Laporan("Stok", ["Apel", "Mangga"])
print(laporan.ke_teks())

Output yang Dihasilkan:

text
Stok: Apel, Mangga

Class Laporan kini hanya berubah bila data laporan berubah; class PenyimpanLaporan hanya berubah bila media simpan berubah — dua alasan berubah menjadi dua class terpisah.

O — Terbuka untuk Ekstensi #

Menambah tier diskon baru semestinya tidak menuntut mengedit rangkaian if/elif yang sudah ada. Daftarkan strategy pada dictionary, lalu tambah tier dengan menambah entri:

python
def reguler(total):
    return 0.0

def silver(total):
    return total * 0.05

def gold(total):
    return total * 0.10

TIER_DISKON = {"reguler": reguler, "silver": silver, "gold": gold}


def hitung_diskon(total, tipe):
    return TIER_DISKON[tipe](total)


print(f"Diskon gold: {hitung_diskon(200_000, 'gold'):,.0f}")

Output yang Dihasilkan:

text
Diskon gold: 20,000

Function hitung_diskon terbuka untuk perilaku baru (daftar tier bisa bertambah) tanpa diedit — pola yang dikenal sebagai strategy.

L — Substitusi #

Subclass harus bisa menggantikan superclass-nya tanpa melanggar kontrak pemanggil. Kontrak class berikut: apply() mengembalikan harga akhir.

python
class Penawaran:
    """Kontrak: apply() mengembalikan harga akhir >= 0."""

    def apply(self, harga):
        raise NotImplementedError


class DiskonNominal(Penawaran):
    def __init__(self, nominal):
        self.nominal = nominal

    def apply(self, harga):
        return max(0.0, harga - self.nominal)


def cetak_harga(penawaran, harga):
    """Kode pemanggil hanya mengandalkan kontrak apply()."""
    print(f"Harga akhir: {penawaran.apply(harga):,.0f}")


cetak_harga(DiskonNominal(5_000), 20_000)

Output yang Dihasilkan:

text
Harga akhir: 15,000

Bayangkan subclass CashbackNominal yang apply()-nya melempar RuntimeError alih-alih mengembalikan harga: setiap pemanggil seperti cetak_harga() yang percaya kontrak akan gagal. Subclass seperti itu melanggar prinsip substitusi — pemanggil terpaksa tahu detail implementasi tiap subclass.

I — Interface yang Kecil #

Pemanggil tidak boleh dipaksa bergantung pada method yang tidak dipakainya. Python menjawabnya dengan duck typing: interface cukup kecil berupa method yang memang dibutuhkan, dan typing.Protocol — dibahas tuntas pada Chapter Protocols dan Structural Typing — membuat kontrak itu eksplisit tanpa pewarisan:

python
from typing import Protocol


class PengirimNotifikasi(Protocol):
    def kirim(self, tujuan: str, pesan: str) -> None: ...

Interface di atas hanya berisi satu method — implementasi bebas punya method lain sebanyak apa pun; kontraknya tetap satu.

D — Bergantung pada Abstraksi #

Class level tinggi tidak perlu mengimpor class konkret. Karena LayananTagihan menerima apa pun yang memenuhi Protocol di atas, pengirim email dan WhatsApp saling menggantikan tanpa menyentuh kode layanan:

python
class EmailPengirim:
    def kirim(self, tujuan: str, pesan: str) -> None:
        print(f"[email] ke {tujuan}: {pesan}")


class WhatsappPengirim:
    def kirim(self, tujuan: str, pesan: str) -> None:
        print(f"[whatsapp] ke {tujuan}: {pesan}")


class LayananTagihan:
    """Bergantung pada abstraksi, bukan pengirim konkret."""

    def __init__(self, pengirim: PengirimNotifikasi):
        self.pengirim = pengirim

    def tagih(self, email: str, jumlah: float) -> None:
        self.pengirim.kirim(email, f"Tagihan Anda Rp{jumlah:,.0f}")


LayananTagihan(EmailPengirim()).tagih("[email protected]", 150_000)
LayananTagihan(WhatsappPengirim()).tagih("[email protected]", 150_000)

Output yang Dihasilkan:

text
[email] ke [email protected]: Tagihan Anda Rp150,000
[whatsapp] ke [email protected]: Tagihan Anda Rp150,000

Pengirim dilewatkan dari luar — teknik dependency injection. Menguji LayananTagihan pun tinggal menyuntik pengirim palsu yang mencatat panggilan.

Composition over Inheritance #

Pewarisan mengikat class anak pada detail induknya. Komposisi memilih jalan lain: class memiliki object yang dibutuhkan, bukan mewarisinya — tema utama Chapter Composition di Section Object-Oriented Programming. Contoh singkasnya:

python
class PengirimEmail:
    def kirim(self, tujuan, pesan):
        print(f"[email] {tujuan}: {pesan}")


class NotifikasiService:
    """Service memilikikan (compose) pengirim, bukan mewarisinya."""

    def __init__(self, pengirim):
        self.pengirim = pengirim

    def peringatan_stok(self, barang, stok):
        self.pengirim.kirim("[email protected]", f"Stok {barang} tersisa {stok}")


NotifikasiService(PengirimEmail()).peringatan_stok("kopi", 3)

Output yang Dihasilkan:

text
[email] [email protected]: Stok kopi tersisa 3

Mengganti pengirim email menjadi pengirim WhatsApp tidak menyentuh NotifikasiService sama sekali — fleksibilitas yang mustahil bila NotifikasiService mewarisikan PengirimEmail.

DRY dengan Hati-hati #

DRY (don’t repeat yourself) sering disalahpahami sebagai perintah menggabungkan semua kode yang mirip. Sandi Metz mengingatkan: duplication is far cheaper than the wrong abstraction — duplikasi jauh lebih murah daripada abstraksi yang salah. Dua function sapa_pagi(nama) dan sapa_siang(nama) yang mirip bukan masalah; memaksakan sapa(waktu, nama, formal, default_koma) yang menyatukan semuanya justru melahirkan parameter yang sebagian besar pemanggil set None. Singkirkan duplikasi saat polanya benar-benar terbukti stabil — biasanya setelah muncul tiga kali.

KISS dan YAGNI #

KISS menyuruh solusi paling sederhana yang bekerja dipilih lebih dulu: validasi umur cukup satu pemeriksaan rentang, bukan class Validator berlapis. YAGNI (you aren’t gonna need it) menyasar fitur yang belum diminta — parser konfigurasi YAML “untuk masa depan” pada skrip yang kini cuma butuh tiga konstanta adalah beban yang harus dirawat tanpa pernah terpakai. Keduanya bukan alasan menulis kode asal; keduanya alasan menunda kerumitan sampai dibutuhkan.

Kohesi dan Kopling #

Dua ukuran ini menjadi dasar hampir semua prinsip di atas. Kohesi mengukur seberapa satu tujuan sebuah module; kopling mengukur seberapa banyak module saling tahu detailnya. Diagram berikut membandingkan keduanya:

Pada skema kopling tinggi, perubahan format tabel stok memaksa modul pesanan ikut diedit. Pada skema kopling rendah, modul pesanan hanya tahu kontrak PengirimNotifikasi — siapa pun yang memenuhi kontrak itu boleh masuk. SOLID, komposisi, dan dependency injection pada akhirnya bekerja untuk tujuan yang sama: kohesi naik, kopling turun.

Prinsip yang paling menentukan keberlanjutan project justru menyangkut pengguna kode Anda di luar tim. Chapter Menjaga Backward Compatibility membahas cara berubah tanpa memutus kode para pemanggil.