Pernah ngalamin situasi di mana kamu cuma mau tambah satu fitur kecil, tapi malah bikin kode jadi berantakan? Kamu buka file A, ubah satu baris, eh ternyata file B ikut error. Fix error di file B, file C malah crash. Akhirnya kamu bengong di depan laptop nanya ke diri sendiri: "kok tambah satu tombol aja sampai setengah hari ya?"
Nah, itu pertanda kalau kode kamu kekurangan struktur. Bukan karena kamu jelek coding-nya, tapi karena belum ada konsep yang ngatur gimana kode harus dibentuk biar gampang diubah tanpa ngerusak bagian lain. SOLID principles itu jawabannya. Bukan sesuatu yang kaku atau akademis banget, tapi lebih ke pedoman praktis yang bikin hidup kamu sebagai developer jadi lebih tenang.
Apa Itu SOLID dan Kenapa Harus Peduli?
SOLID itu akronim dari lima prinsip design yang diperkenalkan oleh Robert C. Martin ( Uncle Bob ) di awal 2000-an. Tiap huruf mewakili satu prinsip yang ngarah ke satu tujuan: bikin kode yang gampang dipahami, gampang diubah, dan gampang di-test. Lima prinsipnya adalah:
- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
Sebelum kamu mikir "itu mah teori doang, gue kerja di startup ngebut fitur terus", tunggu dulu. SOLID bukan bikin kamu nulis kode lebih lama. Justru kalau kamu udah terbiasa, nulis kode SOLID jadi lebih cepat karena kamu nggak perlu mikir "bagian mana lagi yang bakal rusak". Dan kalau kamu kerja tim, teammate kamu bakal grateful banget karena kode kamu predictable.
S - Single Responsibility Principle (SRP)
Intinya: satu class atau satu function cuma boleh punya satu alasan untuk berubah. Kalau kamu punya class yang nangani cetak invoice, kirim email, dan hitung pajak, berarti class itu punya tiga alasan untuk berubah. Pajak berubah karena regulasi, email berubah karena ganti provider, cetak berubah karena ganti template. Tiga alasan beda dalam satu class = recipe for disaster.
Contoh kode yang melanggar SRP di PHP:
class OrderProcessor {
public function processOrder(array $items, $customerId) {
// Hitung total
$total = 0;
foreach ($items as $item) {
$total += $item['price'] * $item['qty'];
}
// Hitung pajak
$tax = $total * 0.11; // PPN 11%
// Simpan ke database
$db = new PDO('mysql:host=localhost;dbname=shop', 'root', '');
$stmt = $db->prepare('INSERT INTO orders (customer_id, total, tax) VALUES (?, ?, ?)');
$stmt->execute([$customerId, $total, $tax]);
// Kirim email konfirmasi
mail($customerId . '@email.com', 'Order Confirmed', 'Total: ' . $total);
// Generate PDF invoice
$pdf = new TCPDF();
$pdf->AddPage();
$pdf->writeHTML('<h1>Invoice</h1><p>Total: ' . $total . '</p>');
$pdf->Output('invoice.pdf', 'F');
}
}
Lihat masalahnya? Satu method ini ngelakuin lima hal sekaligus. Kalau pajak berubah dari 11% ke 12%, kamu harus edit method ini. Kalau ganti dari PHP mail ke SMTP, kamu edit method ini. Kalau ganti library PDF, kamu edit method ini lagi. Setiap edit ada risiko ngerusak bagian lain.
Refactor dengan SRP:
class TaxCalculator {
public function calculate(float $amount): float {
return $amount * 0.11; // PPN 11%
}
}
class OrderRepository {
public function save(int $customerId, float $total, float $tax): int {
$db = new PDO('mysql:host=localhost;dbname=shop', 'root', '');
$stmt = $db->prepare('INSERT INTO orders (customer_id, total, tax) VALUES (?, ?, ?)');
$stmt->execute([$customerId, $total, $tax]);
return (int) $db->lastInsertId();
}
}
class EmailService {
public function sendOrderConfirmation(string $email, float $total): void {
// Bisa ganti ke SMTP, API, atau apa pun tanpa sentuh class lain
mail($email, 'Order Confirmed', 'Total: ' . $total);
}
}
class InvoiceGenerator {
public function generate(int $orderId, float $total): string {
$pdf = new TCPDF();
$pdf->AddPage();
$pdf->writeHTML('<h1>Invoice #' . $orderId . '</h1><p>Total: Rp' . $total . '</p>');
$filename = 'invoice_' . $orderId . '.pdf';
$pdf->Output($filename, 'F');
return $filename;
}
}
class OrderProcessor {
public function __construct(
private TaxCalculator $taxCalculator,
private OrderRepository $repository,
private EmailService $emailService,
private InvoiceGenerator $invoiceGenerator
) {}
public function processOrder(array $items, int $customerId): int {
$total = array_sum(array_map(fn($i) => $i['price'] * $i['qty'], $items));
$tax = $this->taxCalculator->calculate($total);
$orderId = $this->repository->save($customerId, $total, $tax);
$this->emailService->sendOrderConfirmation($customerId . '@email.com', $total);
$this->invoiceGenerator->generate($orderId, $total);
return $orderId;
}
}
Sekarang tiap class punya satu tanggung jawab. Mau ganti library PDF? Edit InvoiceGenerator doang. Mau ganti mail provider? Edit EmailService. Mau ganti tarif pajak? Edit TaxCalculator. Masing-masing terisolasi dan gampang di-test.
O - Open/Closed Principle (OCP)
Class harus open for extension (bisa ditambah fitur baru) tapi closed for modification (nggak perlu diubah kode yang udah ada). Maksudnya, kalau mau tambah fitur baru, kamu nulis kode baru, bukan edit kode lama yang udah berjalan production.
Contoh pelanggaran OCP:
class DiscountCalculator {
public function calculate(float $price, string $customerType): float {
if ($customerType === 'regular') {
return $price; // No discount
} elseif ($customerType === 'silver') {
return $price * 0.95; // 5% off
} elseif ($customerType === 'gold') {
return $price * 0.90; // 10% off
} elseif ($customerType === 'platinum') {
return $price * 0.85; // 15% off
} elseif ($customerType === 'vip') {
return $price * 0.80; // 20% off
}
// Tiap kali ada tipe customer baru, kamu must edit method ini
// dan risiko ngerusak tipe yang udah ada
return $price;
}
}
Setiap kali marketing minta tipe customer baru, kamu harus buka file ini, tambah elseif, dan berdoa kali gak ngerusak yang lain. Solusinya? Pake interface dan polymorphism:
interface DiscountStrategy {
public function apply(float $price): float;
}
class RegularDiscount implements DiscountStrategy {
public function apply(float $price): float {
return $price;
}
}
class SilverDiscount implements DiscountStrategy {
public function apply(float $price): float {
return $price * 0.95;
}
}
class GoldDiscount implements DiscountStrategy {
public function apply(float $price): float {
return $price * 0.90;
}
}
// Mau tambah PlatinumDiscount? Bikin class baru, gak sentuh yang lama
class PlatinumDiscount implements DiscountStrategy {
public function apply(float $price): float {
return $price * 0.85;
}
}
class DiscountCalculator {
private array $strategies = [];
public function registerStrategy(string $type, DiscountStrategy $strategy): void {
$this->strategies[$type] = $strategy;
}
public function calculate(float $price, string $customerType): float {
$strategy = $this->strategies[$customerType] ?? $this->strategies['regular'];
return $strategy->apply($price);
}
}
// Penggunaan:
$calculator = new DiscountCalculator();
$calculator->registerStrategy('regular', new RegularDiscount());
$calculator->registerStrategy('silver', new SilverDiscount());
$calculator->registerStrategy('gold', new GoldDiscount());
$calculator->registerStrategy('platinum', new PlatinumDiscount());
$finalPrice = $calculator->calculate(100000, 'gold'); // 90000
Sekarang nambah tipe customer baru = bikin class baru + daftarin. Nol edit kode yang udah ada. Kamu bahkan bisa daftarin strategy dari file config atau database tanpa sentuh DiscountCalculator sama sekali.
L - Liskov Substitution Principle (LSP)
Prinsip ini ngomong: kalau kamu punya class Child yang inherit dari class Parent, kamu harus bisa ganti Parent dengan Child tanpa bikin program error atau berperilaku aneh. intinya, child class gak boleh nge-break kontrak yang udah disepakati parent.
Contoh klasik yang melanggar LSP:
class Rectangle {
protected float $width;
protected float $height;
public function setWidth(float $w): void { $this->width = $w; }
public function setHeight(float $h): void { $this->height = $h; }
public function getArea(): float { return $this->width * $this->height; }
}
class Square extends Rectangle {
// Square harus maintain width = height
public function setWidth(float $w): void {
$this->width = $w;
$this->height = $w; // Override ini nge-break kontrak parent!
}
public function setHeight(float $h): void {
$this->width = $h;
$this->height = $h; // Sama, nge-break expectation
}
}
// Function ini expect Rectangle, tapi kalo dikasih Square...
function resizeShape(Rectangle $shape): void {
$shape->setWidth(10);
$shape->setHeight(20);
// Kalau $shape adalah Rectangle: area = 200
// Kalau $shape adalah Square: area = 400 (20x20, bukan 10x20!)
echo "Area: " . $shape->getArea();
}
Di sini Square melanggar LSP karena override setWidth dan setHeight dengan perilaku yang beda dari Rectangle. Function yang expect Rectangle bakal dapat hasil yang aneh kalau dikasih Square. Solusinya: jangan wariskan hubungan yang nggak benar-benar is-a. Square dan Rectangle punya hubungan matematis, tapi dari sudut pandang behavior mereka beda.
Contoh praktis LSP di aplikasi nyata:
interface PaymentGateway {
public function charge(float $amount, string $currency): array;
}
class StripeGateway implements PaymentGateway {
public function charge(float $amount, string $currency): array {
// Panggil Stripe API
return ['status' => 'success', 'transaction_id' => 'stripe_xxx'];
}
}
class CODGateway implements PaymentGateway {
public function charge(float $amount, string $currency): array {
// COD gak charge kartu, tapi harus tetap return format yang sama
return ['status' => 'pending', 'transaction_id' => 'cod_' . uniqid()];
}
}
// Function ini bisa terima PAYMENT GATEWAY apapun
// tanpa perlu tau implementasinya
function processPayment(PaymentGateway $gateway, float $amount): void {
$result = $gateway->charge($amount, 'IDR');
if ($result['status'] === 'success') {
echo "Pembayaran berhasil!";
} elseif ($result['status'] === 'pending') {
echo "Menunggu konfirmasi pembayaran.";
}
}
StripeGateway dan CODGateway sama-sama implement PaymentGateway dengan kontrak yang sama: return array dengan key status dan transaction_id. Kamu bisa ganti-ganti gateway tanpa ubah kode yang manggil mereka. Itu LSP yang bener.
I - Interface Segregation Principle (ISP)
Jangan bikin interface yang gemuk. Lebih baik punya banyak interface kecil yang spesifik daripada satu interface raksasa yang ngutang method-method yang gak semua class butuhin. Kalau class implement interface tapi ada method yang gak dipake, itu pertanda interface kebablasan.
Contoh interface yang melanggar ISP:
interface Worker {
public function work(): void;
public function eat(): void;
public function sleep(): void;
}
class HumanWorker implements Worker {
public function work(): void { echo "Ngoding..."; }
public function eat(): void { echo "Makan mie ayam..."; }
public function sleep(): void { echo "Tidur 4 jam..."; }
// OK, human bisa semua
}
class RobotWorker implements Worker {
public function work(): void { echo "Eksekusi task..."; }
public function eat(): void {
// Robot gak makan! Tapi harus implement karena interface
throw new Exception("Robot gak makan");
}
public function sleep(): void {
// Robot gak tidur!
throw new Exception("Robot gak tidur");
}
}
RobotWorker dipaksa implement eat() dan sleep() padahal gak butuh. Ini code smell yang bikin class penuh exception atau empty method yang gak jelas. Refactor dengan interface yang lebih kecil:
interface Workable {
public function work(): void;
}
interface Eatable {
public function eat(): void;
}
interface Sleepable {
public function sleep(): void;
}
class HumanWorker implements Workable, Eatable, Sleepable {
public function work(): void { echo "Ngoding..."; }
public function eat(): void { echo "Makan mie ayam..."; }
public function sleep(): void { echo "Tidur 4 jam..."; }
}
class RobotWorker implements Workable {
public function work(): void { echo "Eksekusi task 24/7..."; }
// Gak perlu eat() atau sleep(), bersih!
}
class AIAssistant implements Workable, Sleepable {
public function work(): void { echo "Generate kode..."; }
public function sleep(): void { echo "Maintenance mode..."; }
// Hanya implement yang dibutuhkan
}
Sekarang tiap class cuma implement interface yang mereka butuhin. Kalau kamu butuh worker yang bisa kerja, type-hint Workable. Kalau butuh yang bisa makan, type-hint Eatable. Fleksibel dan gak ada method yang nganggur.
D - Dependency Inversion Principle (DIP)
Ini prinsip terakhir dan mungkin yang paling powerful. Dua aturan: (1) high-level module gak boleh depend ke low-level module, keduanya harus depend ke abstraction. (2) Abstraction gak boleh depend ke detail, detail yang harus depend ke abstraction.
Bahasa sederhananya: class yang penting (business logic) gak boleh langsung bergantung ke class yang teknis (database, API, file system). Keduanya harus lewat interface.
// ANTI-PATTERN: High-level langsung depend ke low-level
class UserService {
private MySQLDatabase $db; // Hard dependency!
public function __construct() {
$this->db = new MySQLDatabase('localhost', 'root', '', 'app');
}
public function registerUser(string $email): void {
if ($this->db->query("SELECT * FROM users WHERE email=?", [$email])) {
throw new Exception("Email udah terdaftar");
}
$this->db->query("INSERT INTO users (email) VALUES (?)", [$email]);
}
}
// Kalau mau pindah dari MySQL ke PostgreSQL atau MongoDB,
// kamu harus bongkar UserService. Itu DIP violation.
Refactor dengan DIP:
// Abstraction (interface) - gak peduli implementasinya
interface UserRepositoryInterface {
public function findByEmail(string $email): ?array;
public function save(array $user): void;
}
// Low-level module depend ke abstraction, bukan sebaliknya
class MySQLUserRepository implements UserRepositoryInterface {
private PDO $db;
public function __construct(string $host, string $user, string $pass, string $dbname) {
$this->db = new PDO("mysql:host=$host;dbname=$dbname", $user, $pass);
}
public function findByEmail(string $email): ?array {
$stmt = $this->db->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);
$result = $stmt->fetch();
return $result ?: null;
}
public function save(array $user): void {
$stmt = $this->db->prepare('INSERT INTO users (email) VALUES (?)');
$stmt->execute([$user['email']]);
}
}
class MongoUserRepository implements UserRepositoryInterface {
// Implementasi MongoDB, tapi kontraknya sama
public function findByEmail(string $email): ?array {
// MongoDB query
return null;
}
public function save(array $user): void {
// MongoDB insert
}
}
// High-level module depend ke abstraction, bukan concrete class
class UserService {
private UserRepositoryInterface $repository;
public function __construct(UserRepositoryInterface $repository) {
$this->repository = $repository;
}
public function registerUser(string $email): void {
if ($this->repository->findByEmail($email)) {
throw new Exception("Email sudah terdaftar");
}
$this->repository->save(['email' => $email]);
}
}
// Wiring di composition root (entry point aplikasi)
$repo = new MySQLUserRepository('localhost', 'root', '', 'app');
$service = new UserService($repo);
$service->registerUser('[email protected]');
// Mau pindah ke MongoDB? Gak sentuh UserService:
// $repo = new MongoUserRepository('mongodb://localhost:27017');
// $service = new UserService($repo);
Inilah kenapa framework modern kayak Laravel, Symfony, dan CodeIgniter 4 punya dependency injection container. Mereka ngimplementasin DIP secara otomatis. Kamu cuma define interface di constructor, framework yang nyuntik implementasinya.
Gimana Mulai Terapkan SOLID Tanpa Overengineering?
Sering kali orang baca tentang SOLID, langsung bikin 20 interface dan 50 class buat fitur sederhana. Itu overengineering. SOLID bukan target, tapi arah. Kamu gak harus langsung perfect. Mulai dari yang paling gampang:
- SRP dulu: Pisahkan function yang ngelakuin terlalu banyak hal. Ini paling gampang dan impact-nya paling besar.
- OCP lewat interface: Kalau kamu lihat pattern if-elseif-elseif yang ngecek "tipe", ganti dengan strategy pattern. Tunggu sampai ada minimal 3 cabang sebelum refactor.
- DIP lewat constructor injection: Jangan new class di dalam class lain. Terima dependency dari luar via constructor. Framework CI4 dan Laravel udah support ini.
- LSP dan ISP: Ini lebih advanced. Terapin kalau kamu udah punya hierarchy class atau multiple implementers. Jangan premature abstract.
Aturan praktis yang saya pake sendiri: kalau sebuah class lewat 200 baris, cek apakah ada yang bisa dipisah (SRP). Kalau ada switch/if-elseif berdasarkan tipe, pikirin strategy pattern (OCP). Kalau constructor butuh new something, ganti dengan interface injection (DIP). Itu aja udah cukup buat ngubah kualitas kode kamu signifikan.
Kesimpulan
SOLID principles bukan teori akademis yang cuma cocok di buku. Itu pedoman praktis yang muncul dari tahun-tahun pengalaman developers menghadapi kode yang susah diubah. Setiap prinsip ngajawab satu masalah spesifik: SRP ngajarin misahin tanggung jawab, OCP ngajarin extend tanpa modify, LSP ngajarin inheritance yang bener, ISP ngajarin interface yang lean, dan DIP ngajarin decouple high-level dari low-level.
Kamu gak harus langsung terapin semua sekaligus. Mulai dari SRP, terus naik ke OCP dan DIP. Setelah itu LSP dan ISP bakal lebih natural. Yang penting: setiap kali kamu nulis kode, tanya ke diri sendiri, "kalau besok requirement berubah, kode ini bakal susah diubah nggak ya?" Kalau jawabannya iya, mungkin udah saatnya apply salah satu prinsip SOLID.
Bagaimana dengan kamu? Prinsip SOLID yang mana yang udah kamu terapin di project? Atau malah baru denger sekarang? Share pengalaman kamu di kolom komentar, siapa tahu ada tips yang bisa saling membantu!