Bab 46 — Pola Desain dan Arsitektur: Menata Kode yang Makin Besar

Seri A1 — Rekayasa Perangkat Lunak Profesional

CatatanDi bab ini, Anda akan:
  • Mengenal tiga pola desain yang paling sering dipakai: Strategy, Factory, Observer
  • Memahami prinsip SOLID lewat contoh sebelum dan sesudah
  • Memisahkan “apa yang dikerjakan” dari “di mana datanya” dengan port dan adapter
  • Menata aplikasi menjadi lapisan (antarmuka, layanan, domain, adapter)
  • Memeriksa aturan arsitektur secara otomatis, lalu menata ulang proyek bersambung Seri A1

Semua skrip bab ini ada di folder kode/bab46/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Paket kita dari Bab 45 sudah rapi di luar. Tetapi di dalam, bayangkan sebuah dapur restoran yang awalnya satu orang memasak, mencuci, dan menghitung uang. Saat restoran ramai, kekacauan terjadi: juru masak sibuk menghitung uang, kasir sibuk mencuci piring. Solusinya pembagian tugas. Dalam perangkat lunak, pembagian tugas yang sudah teruji bertahun-tahun disebut pola desain (resep penyelesaian masalah yang sering muncul) dan arsitektur (denah dapur secara keseluruhan).

CatatanINGAT

Pola desain adalah alat, bukan hiasan. Menerapkan pola di tempat yang tidak membutuhkannya hanya menambah kerumitan. Python pun sering menyederhanakan pola dengan fitur bahasa (fungsi sebagai objek, dict, modul), jadi banyak “pola” di bawah ini ternyata hanya beberapa baris.

Strategy: rumus yang bisa ditukar

Anda punya banyak cara menghitung volume pohon (silinder, kerucut, dengan faktor bentuk). Daripada if bercabang di mana-mana, bungkus tiap rumus sebagai fungsi dan titipkan ke pemakainya:

01_strategy.py
# Bab 46 - Pola Strategy: rumus bisa ditukar tanpa mengubah pemakainya
import math


def volume_silinder(diameter_cm, tinggi_m):
    return math.pi * (diameter_cm / 200) ** 2 * tinggi_m


def volume_kerucut(diameter_cm, tinggi_m):
    return volume_silinder(diameter_cm, tinggi_m) / 3


def volume_bentuk_pohon(diameter_cm, tinggi_m):
    return volume_silinder(diameter_cm, tinggi_m) * 0.5     # faktor bentuk contoh


RUMUS = {
    "silinder": volume_silinder,
    "kerucut": volume_kerucut,
    "pohon": volume_bentuk_pohon,
}


def hitung_volume(diameter_cm, tinggi_m, rumus):
    """Pemakai tidak peduli rumus mana; ia hanya memanggilnya."""
    return rumus(diameter_cm, tinggi_m)


for nama, fungsi in RUMUS.items():
    print(f"{nama:9} {hitung_volume(30, 20, fungsi):.3f} m3")
silinder  1.414 m3
kerucut   0.471 m3
pohon     0.707 m3

Pada pohon berdiameter 30 cm dan tinggi 20 m, silinder memberi 1,414 m³, kerucut sepertiganya (0,471), dan faktor bentuk 0,5 memberi 0,707. Fungsi hitung_volume tidak berubah sama sekali bila Anda menambah rumus keempat. Itulah inti Strategy: algoritma yang bisa ditukar.

Factory: satu pintu pembuat objek

Data lapangan datang dalam beberapa jenis catatan (pohon, ikan, …). Factory (pabrik) memusatkan “jenis ini dibuat jadi objek apa”:

02_factory.py
# Bab 46 - Pola Factory: satu pintu untuk membuat objek sesuai jenis catatan
from dataclasses import dataclass


@dataclass
class CatatanPohon:
    plot: str
    diameter_cm: float


@dataclass
class CatatanIkan:
    kolam: str
    bobot_rata_g: float


PABRIK = {"pohon": CatatanPohon, "ikan": CatatanIkan}


def buat_catatan(data):
    """Membuat objek yang tepat dari dict yang punya kunci 'tipe'."""
    data = dict(data)
    tipe = data.pop("tipe")
    if tipe not in PABRIK:
        raise ValueError(f"tipe catatan tidak dikenal: {tipe!r} (pilihan: {sorted(PABRIK)})")
    return PABRIK[tipe](**data)


masuk = [
    {"tipe": "pohon", "plot": "A", "diameter_cm": 32.5},
    {"tipe": "ikan", "kolam": "K1", "bobot_rata_g": 540.5},
    {"tipe": "ternak", "kandang": "S1"},
]
for item in masuk:
    try:
        print(buat_catatan(item))
    except ValueError as galat:
        print("DITOLAK:", galat)
CatatanPohon(plot='A', diameter_cm=32.5)
CatatanIkan(kolam='K1', bobot_rata_g=540.5)
DITOLAK: tipe catatan tidak dikenal: 'ternak' (pilihan: ['ikan', 'pohon'])

Tipe ternak belum didaftarkan, sehingga ditolak dengan pesan jelas yang menyebut pilihan yang ada. Menambah tipe baru cukup menambah satu baris di PABRIK.

Observer: memberi kabar tanpa mengenal penerima

Saat pH kolam dicatat, mungkin ada yang perlu tahu: log riwayat, sistem peringatan, dasbor. Pencatat tidak ingin mengenal mereka satu per satu. Dengan Observer, penerima mendaftar, dan pencatat memanggil semuanya:

03_observer.py
# Bab 46 - Pola Observer: pencatat memberi tahu "pendengar" tanpa mengenal mereka
class PencatatPh:
    def __init__(self):
        self._pendengar = []

    def daftar(self, pendengar):
        self._pendengar.append(pendengar)

    def catat(self, kolam, ph):
        for pendengar in self._pendengar:
            pendengar(kolam, ph)


riwayat = []
peringatan = []


def simpan_riwayat(kolam, ph):
    riwayat.append((kolam, ph))


def awasi_ph(kolam, ph):
    if not 6.5 <= ph <= 8.5:        # batas contoh, bukan anjuran budidaya
        peringatan.append(f"kolam {kolam}: pH {ph} di luar batas contoh 6,5-8,5")


pencatat = PencatatPh()
pencatat.daftar(simpan_riwayat)
pencatat.daftar(awasi_ph)

for kolam, ph in [("K1", 7.2), ("K2", 6.1), ("K3", 8.9)]:
    pencatat.catat(kolam, ph)

print("riwayat:", riwayat)
for pesan in peringatan:
    print("PERINGATAN:", pesan)
riwayat: [('K1', 7.2), ('K2', 6.1), ('K3', 8.9)]
PERINGATAN: kolam K2: pH 6.1 di luar batas contoh 6,5-8,5
PERINGATAN: kolam K3: pH 8.9 di luar batas contoh 6,5-8,5

Kolam K2 (pH 6,1) dan K3 (pH 8,9) di luar batas contoh 6,5 sampai 8,5; batas ini hanya untuk latihan, bukan anjuran budidaya. Perhatikan bahwa PencatatPh tidak tahu ada fungsi awasi_ph; menambah pendengar baru tidak mengubah pencatat.

SOLID: lima prinsip menata kelas dan fungsi

SOLID adalah singkatan lima prinsip yang membuat kode mudah diubah:

Huruf Prinsip Artinya sehari-hari
S Single Responsibility satu bagian, satu tugas
O Open/Closed terbuka untuk ditambah, tertutup untuk dibongkar
L Liskov Substitution pengganti harus bisa dipakai di tempat yang digantikan
I Interface Segregation antarmuka kecil dan spesifik, bukan satu antarmuka raksasa
D Dependency Inversion bergantung pada perjanjian, bukan pada kelas konkret

Dua yang pertama kita lihat sebelum-sesudah:

05_solid.py
# Bab 46 - SOLID: tanggung jawab tunggal (S) dan terbuka untuk perluasan (O)

# ---- SEBELUM: satu fungsi mengerjakan segalanya, dan harus diubah setiap ada jenis baru ----
def laporan_serba_bisa(pohon):
    total = 0
    for p in pohon:
        if p["jenis"] == "Jati":
            total += p["volume"] * 4_000_000
        elif p["jenis"] == "Meranti":
            total += p["volume"] * 2_500_000
        # jenis baru? Fungsi ini harus dibongkar lagi...
    return f"Total: Rp {total:,.0f}"          # hitung DAN format tercampur


# ---- SESUDAH: tiap bagian punya satu tugas; jenis baru cukup menambah data ----
HARGA = {"Jati": 4_000_000, "Meranti": 2_500_000}


def hitung_total(pohon, harga):
    return sum(p["volume"] * harga[p["jenis"]] for p in pohon)


def format_rupiah(angka):
    return f"Total: Rp {angka:,.0f}"


pohon = [
    {"jenis": "Jati", "volume": 1.2},
    {"jenis": "Meranti", "volume": 0.8},
]
print(laporan_serba_bisa(pohon))
print(format_rupiah(hitung_total(pohon, HARGA)))

HARGA["Mahoni"] = 3_000_000                   # perluasan: tanpa menyentuh fungsi mana pun
pohon.append({"jenis": "Mahoni", "volume": 0.5})
print(format_rupiah(hitung_total(pohon, HARGA)))
print("versi lama untuk Mahoni (diam-diam diabaikan):", laporan_serba_bisa(pohon))
Total: Rp 6,800,000
Total: Rp 6,800,000
Total: Rp 8,300,000
versi lama untuk Mahoni (diam-diam diabaikan): Total: Rp 6,800,000

Baris keempat memperlihatkan bahaya versi lama: setelah ada pohon Mahoni, fungsi lama diam-diam mengabaikannya (totalnya tetap 6.800.000), padahal versi baru menghitung 8.300.000. Bug jenis ini licin karena tidak ada error. Versi baru “terbuka untuk perluasan”: jenis baru cukup menambah data.

Port dan adapter, serta injeksi dependensi

Prinsip D melahirkan ide yang sangat berguna: port (perjanjian: “saya butuh sesuatu yang bisa menyimpan dan membaca”) dan adapter (cara nyata memenuhinya: memori, berkas JSON, basis data). Di Python, port ditulis dengan typing.Protocol:

04_port_adapter.py
# Bab 46 - Port dan Adapter: satu perjanjian, banyak cara menyimpan
import json
from pathlib import Path
from typing import Protocol


class Penyimpanan(Protocol):
    def simpan(self, catatan: dict) -> None: ...
    def semua(self) -> list[dict]: ...


class PenyimpananMemori:
    def __init__(self):
        self._data = []

    def simpan(self, catatan):
        self._data.append(catatan)

    def semua(self):
        return list(self._data)


class PenyimpananJson:
    def __init__(self, jalur):
        self._jalur = Path(jalur)

    def simpan(self, catatan):
        data = self.semua()
        data.append(catatan)
        self._jalur.write_text(json.dumps(data), encoding="utf-8")

    def semua(self):
        if not self._jalur.exists():
            return []
        return json.loads(self._jalur.read_text(encoding="utf-8"))


def catat_panen(penyimpanan: Penyimpanan):
    """Fungsi ini tidak tahu data disimpan di mana."""
    penyimpanan.simpan({"blok": "A", "hasil_kg": 245.5})
    penyimpanan.simpan({"blok": "B", "hasil_kg": 310.0})
    return penyimpanan.semua()


tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
berkas = tmp / "panen.json"
berkas.unlink(missing_ok=True)

hasil_memori = catat_panen(PenyimpananMemori())
hasil_json = catat_panen(PenyimpananJson(berkas))
print("memori :", hasil_memori)
print("json   :", hasil_json)
print("kedua adapter memberi hasil sama:", hasil_memori == hasil_json)
memori : [{'blok': 'A', 'hasil_kg': 245.5}, {'blok': 'B', 'hasil_kg': 310.0}]
json   : [{'blok': 'A', 'hasil_kg': 245.5}, {'blok': 'B', 'hasil_kg': 310.0}]
kedua adapter memberi hasil sama: True

Fungsi catat_panen bekerja dengan adapter mana pun. Keuntungan terbesarnya adalah saat menguji. Bila bagian dalam dititipkan dari luar (dependency injection, injeksi dependensi), tes bisa memakai pengganti palsu:

06_injeksi_dependensi.py
# Bab 46 - Injeksi dependensi (D di SOLID): bagian dalam dititipkan dari luar, jadi mudah diuji
class RepoNyata:
    """Dalam aplikasi sungguhan ini membaca basis data; di sini hanya pura-pura."""

    def semua_diameter(self):
        return [32.5, 45.2, 28.0]


class RepoPalsu:
    """Pengganti untuk tes: isinya kita tentukan sendiri."""

    def semua_diameter(self):
        return [10.0, 20.0]


class LayananRataRata:
    def __init__(self, repo):
        self._repo = repo            # DITITIPKAN dari luar, tidak dibuat sendiri di dalam

    def rata_rata(self):
        angka = self._repo.semua_diameter()
        return sum(angka) / len(angka)


# Tes: dengan repo palsu, hasilnya bisa diperkirakan persis
tes = LayananRataRata(RepoPalsu())
assert tes.rata_rata() == 15.0
print("tes dengan repo palsu lulus: rata-rata =", tes.rata_rata())

# Pemakaian nyata: layanan yang sama, penyimpanan lain
nyata = LayananRataRata(RepoNyata())
print("dengan repo nyata: rata-rata =", round(nyata.rata_rata(), 2))
tes dengan repo palsu lulus: rata-rata = 15.0
dengan repo nyata: rata-rata = 35.23

Tes dengan repo palsu bisa memastikan rata-rata persis 15,0 karena isinya kita tentukan sendiri. Dengan basis data sungguhan, tes harus menyiapkan data dulu dan jadi lambat.

TipTIPS

Aturan praktis: bila sebuah kelas membuat sendiri koneksi basis data, berkas, atau jaringan di dalamnya, ia sulit diuji. Ubah agar koneksi itu diterima lewat argumen (__init__).

Arsitektur berlapis dan hexagonal ringkas

Menggabungkan semuanya, kita menata aplikasi menjadi lapisan:

Arsitektur berlapis: panah ketergantungan hanya menunjuk ke dalam, ke domain.
  • Domain: aturan inti (volume, nilai kayu) dan port. Tidak mengenal siapa pun.
  • Layanan: alur kerja (“hitung rekap dari semua pohon”). Hanya tahu domain.
  • Adapter: berkas, basis data, layanan luar; memenuhi port milik domain.
  • Antarmuka: API, CLI, dasbor; memanggil layanan.

Gaya ini sering disebut arsitektur hexagonal (atau ports and adapters): inti aplikasi di tengah, dunia luar menempel lewat adapter. Intinya satu aturan: ketergantungan menunjuk ke dalam. Domain tidak boleh mengimpor adapter.

Proyek Bersambung A1: ditata ulang menjadi berlapis

Kita menata ulang rekap nilai kayu menjadi paket lapangan_v2 dengan empat lapisan. Jalankan layanan yang sama dengan dua adapter berbeda:

07_proyek_arsitektur.py
# Bab 46 - Proyek bersambung A1: aplikasi berlapis; adapter ditukar, hasilnya sama
import sys
from pathlib import Path

folder = Path(__file__).parent
sys.path.insert(0, str(folder))

from lapangan_v2.adaptor.repo_csv import RepoCsv
from lapangan_v2.adaptor.repo_memori import RepoMemori
from lapangan_v2.antarmuka.laporan import cetak_laporan
from lapangan_v2.domain.model import Pohon
from lapangan_v2.layanan.rekap import LayananRekap

contoh = [
    Pohon("Jati", "A", 32.5, 18.0), Pohon("Meranti", "A", 45.2, 22.0),
    Pohon("Mahoni", "B", 28.0, 15.0), Pohon("Jati", "B", 51.5, 20.0),
    Pohon("Sengon", "C", 24.0, 12.0), Pohon("Jati", "A", 29.9, 16.0),
    Pohon("Meranti", "C", 38.0, 19.0), Pohon("Mahoni", "A", 30.0, 14.0),
]

dari_memori = LayananRekap(RepoMemori(contoh))
dari_csv = LayananRekap(RepoCsv(folder / "data" / "pohon_contoh.csv"))

print("== sumber: memori ==")
cetak_laporan(dari_memori)
print("== sumber: berkas CSV ==")
cetak_laporan(dari_csv)
print("hasil sama:", dari_memori.nilai_per_plot() == dari_csv.nilai_per_plot())
== sumber: memori ==
  plot A: Rp 11,130,411
  plot B: Rp 9,717,723
  plot C: Rp 2,964,954
  jumlah per jenis: {'Jati': 3, 'Mahoni': 2, 'Meranti': 2, 'Sengon': 1}
== sumber: berkas CSV ==
  plot A: Rp 11,130,411
  plot B: Rp 9,717,723
  plot C: Rp 2,964,954
  jumlah per jenis: {'Jati': 3, 'Mahoni': 2, 'Meranti': 2, 'Sengon': 1}
hasil sama: True

Dua sumber data (daftar di memori dan berkas CSV) memberi hasil identik (hasil sama: True), padahal kode layanan dan antarmuka tidak diubah sama sekali. Itulah manfaat nyata pemisahan lapisan.

Aturan arsitektur yang hanya tertulis di dokumen cepat dilanggar. Maka kita memeriksanya dengan program, membaca semua import dengan modul ast:

08_cek_ketergantungan.py
# Bab 46 - Memeriksa aturan arsitektur secara otomatis: domain tidak boleh mengimpor lapisan luar
import ast
from pathlib import Path

PAKET = "lapangan_v2"
# lapisan -> lapisan yang BOLEH diimpor
BOLEH = {
    "domain": set(),
    "layanan": {"domain"},
    "adaptor": {"domain"},
    "antarmuka": {"layanan", "domain"},
}


def impor_internal(sumber):
    """Daftar lapisan yang diimpor sebuah modul (hanya dari paket kita)."""
    hasil = set()
    for node in ast.walk(ast.parse(sumber)):
        if isinstance(node, ast.ImportFrom) and node.module and node.module.startswith(PAKET + "."):
            hasil.add(node.module.split(".")[1])
    return hasil


def periksa(sumber, lapisan):
    return sorted(impor_internal(sumber) - BOLEH[lapisan] - {lapisan})


akar = Path(__file__).parent / PAKET
pelanggaran = 0
for lapisan in BOLEH:
    for berkas in sorted((akar / lapisan).glob("*.py")):
        if berkas.name == "__init__.py":
            continue
        salah = periksa(berkas.read_text(encoding="utf-8"), lapisan)
        status = "OK" if not salah else f"LANGGAR: mengimpor {salah}"
        pelanggaran += len(salah)
        print(f"{lapisan + '/' + berkas.name:24} {status}")
print("jumlah pelanggaran:", pelanggaran)

# Uji pemeriksa itu sendiri dengan kode buruk buatan:
buruk = f"from {PAKET}.adaptor.repo_csv import RepoCsv"
print("kode buruk (domain mengimpor adaptor) terdeteksi:", periksa(buruk, "domain"))
domain/model.py          OK
domain/port.py           OK
layanan/rekap.py         OK
adaptor/repo_csv.py      OK
adaptor/repo_memori.py   OK
antarmuka/laporan.py     OK
jumlah pelanggaran: 0
kode buruk (domain mengimpor adaptor) terdeteksi: ['adaptor']

Semua modul patuh (0 pelanggaran). Baris terakhir menguji pemeriksanya sendiri dengan kode buruk buatan, dan ia terdeteksi. Pemeriksa seperti ini bisa dipasang di CI (Bab 48) agar pelanggaran tertangkap otomatis.

Latihan

  1. Tambahkan rumus keempat volume_paraboloid (setengah silinder, dengan alasan faktor bentuk 0,5 sama) ke RUMUS dan jalankan lagi. Apakah hitung_volume perlu diubah?
  2. Di 03_observer.py, tambahkan pendengar ketiga yang menghitung berapa kali pencatatan terjadi, tanpa mengubah kelas PencatatPh.
  3. Tulis adapter ketiga RepoJson untuk lapangan_v2 yang membaca daftar pohon dari berkas JSON. Pastikan 08_cek_ketergantungan.py tetap 0 pelanggaran.
  4. Tantangan: ubah LayananRekap agar menerima fungsi harga (strategy) lewat __init__, lalu uji dengan RepoMemori dan harga tetap 1.000.000 per m³.

Ringkasan Bab

  • Pola desain = resep untuk masalah yang berulang; gunakan hanya bila memang perlu.
  • Strategy (rumus bisa ditukar), Factory (satu pintu pembuat objek), Observer (kabar tanpa kenal penerima).
  • SOLID: satu tugas per bagian, terbuka untuk ditambah, pengganti sepadan, antarmuka kecil, bergantung pada perjanjian.
  • Port dan adapter plus injeksi dependensi membuat kode mudah diuji dan ditukar.
  • Arsitektur berlapis: antarmuka, layanan, domain, adapter; ketergantungan hanya menunjuk ke dalam. Periksa aturannya dengan program.

Selanjutnya, di Bab 47: Python modern (3.12 sampai 3.14), fitur baru yang membuat kode lebih ringkas dan aman.