SOLID Principles dalam Pembuatan Software
Hai gaess, sebagai programmer kita pasti sudah banyak menulis kode program dalam bahasa apa pun: JavaScript, TypeScript, Python, PHP, Java, C#, Go, dan lain-lain. Tapi pernahkah kita berpikir, apakah kode yang kita tulis sudah cukup rapi? Apakah gampang diubah kalau requirement berubah? Apakah mudah dites dan dibaca ulang beberapa bulan lagi?
Salah satu prinsip yang bisa membantu kita menulis kode yang lebih sehat adalah SOLID. Prinsip ini sering dipakai dalam pemrograman berorientasi objek atau Object-Oriented Programming (OOP). Kalau diterapkan dengan tepat, kode bisa jadi lebih mudah di-maintain, lebih fleksibel, lebih mudah dites, dan lebih aman ketika dikembangkan.
Supaya contoh-contohnya lebih mudah diikuti, kita akan memakai TypeScript. Dengan class, interface, dan type system yang jelas, konsep seperti tanggung jawab class, kontrak antar object, dan dependency injection bisa terlihat lebih konkret.
SOLID terdiri dari lima prinsip:
- SRP: Single Responsibility Principle
- OCP: Open/Closed Principle
- LSP: Liskov Substitution Principle
- ISP: Interface Segregation Principle
- DIP: Dependency Inversion Principle
1. SRP: Single Responsibility Principle
Definisi singkatnya: sebuah class sebaiknya hanya punya satu alasan untuk berubah.
Artinya, jangan memasukkan terlalu banyak tanggung jawab ke dalam satu class. Kalau sebuah class mengurus user, order, diskon, email, dan laporan sekaligus, class itu akan cepat menjadi besar dan sulit dirawat.
Contoh yang melanggar SRP:
type UserData = {
name: string;
email: string;
};
type OrderData = {
total: number;
};
class UserService {
createUser(user: UserData) {
// Kode untuk membuat user
}
createOrder(order: OrderData) {
// Kode untuk membuat order
}
cancelOrder(orderId: string) {
// Kode untuk membatalkan order
}
calculateDiscount(order: OrderData) {
// Kode untuk menghitung diskon
return order.total * 0.1;
}
}
Masalahnya, UserService punya terlalu banyak tanggung jawab: user, order, dan diskon. Kalau logic diskon berubah, class user ikut berubah. Kalau logic order berubah, class user juga ikut berubah.
Versi yang lebih sesuai SRP:
type UserData = {
name: string;
email: string;
};
type OrderData = {
total: number;
};
class UserService {
createUser(user: UserData) {
// Kode yang hanya berkaitan dengan user
}
}
class OrderService {
createOrder(order: OrderData) {
// Kode untuk membuat order
}
cancelOrder(orderId: string) {
// Kode untuk membatalkan order
}
}
class DiscountService {
calculate(order: OrderData) {
return order.total * 0.1;
}
}
Sekarang masing-masing class punya fokus yang jelas. UserService berubah ketika aturan user berubah. OrderService berubah ketika aturan order berubah. DiscountService berubah ketika aturan diskon berubah.
Keuntungan SRP:
- Kode lebih kecil dan mudah dibaca
- Perubahan lebih terisolasi
- Testing lebih mudah
- Coupling antar bagian kode berkurang
2. OCP: Open/Closed Principle
Definisi singkatnya: kode sebaiknya terbuka untuk ditambah, tapi tertutup untuk diubah.
Maksudnya, ketika ada requirement baru, kita sebisa mungkin menambah class atau implementasi baru, bukan terus-menerus mengedit logic inti yang sudah ada.
Contoh yang melanggar OCP:
type Shape =
| { type: "rectangle"; width: number; height: number }
| { type: "circle"; radius: number };
class AreaCalculator {
calculate(shape: Shape) {
if (shape.type === "rectangle") {
return shape.width * shape.height;
}
if (shape.type === "circle") {
return Math.PI * shape.radius * shape.radius;
}
return 0;
}
}
Kode ini masih terlihat sederhana. Tapi kalau nanti ada triangle, square, atau ellipse, kita harus mengedit AreaCalculator terus. Lama-lama class ini penuh dengan percabangan.
Versi yang lebih sesuai OCP:
interface Shape {
area(): number;
}
class Rectangle implements Shape {
constructor(
private readonly width: number,
private readonly height: number,
) {}
area() {
return this.width * this.height;
}
}
class Circle implements Shape {
constructor(private readonly radius: number) {}
area() {
return Math.PI * this.radius * this.radius;
}
}
class AreaCalculator {
calculate(shape: Shape) {
return shape.area();
}
}
Ketika ada requirement baru untuk menghitung segitiga, kita cukup menambah class baru:
class Triangle implements Shape {
constructor(
private readonly base: number,
private readonly height: number,
) {}
area() {
return (this.base * this.height) / 2;
}
}
const calculator = new AreaCalculator();
const shapes: Shape[] = [
new Rectangle(10, 5),
new Circle(6),
new Triangle(4, 5),
];
for (const shape of shapes) {
console.log(calculator.calculate(shape));
}
Perhatikan: AreaCalculator tidak perlu diubah. Kita hanya menambah implementasi baru yang mengikuti kontrak Shape.
Keuntungan OCP:
- Lebih mudah menambah fitur baru
- Logic inti lebih stabil
- Risiko bug dari perubahan kode lama berkurang
- Testing bisa fokus ke implementasi baru
3. LSP: Liskov Substitution Principle
Definisi singkatnya: class turunan harus bisa menggantikan parent class tanpa merusak perilaku program.
Masalah LSP sering muncul ketika kita memaksakan inheritance. Contoh klasiknya: tidak semua burung bisa terbang.
Contoh yang melanggar LSP:
class Bird {
fly() {
console.log("I'm flying...");
}
}
class Sparrow extends Bird {
fly() {
console.log("I'm flying like a sparrow...");
}
}
class Kiwi extends Bird {
fly() {
throw new Error("Kiwi cannot fly");
}
}
const birds: Bird[] = [new Sparrow(), new Kiwi()];
for (const bird of birds) {
bird.fly();
}
Secara teknis Kiwi adalah Bird, tapi ketika kode menganggap semua Bird bisa fly(), program bisa error. Ini tanda bahwa abstraksinya kurang tepat.
Versi yang lebih sesuai LSP:
interface WalkingBird {
walk(): void;
}
interface FlyingBird extends WalkingBird {
fly(): void;
}
class Sparrow implements FlyingBird {
walk() {
console.log("Sparrow is walking...");
}
fly() {
console.log("Sparrow is flying...");
}
}
class Kiwi implements WalkingBird {
walk() {
console.log("Kiwi is walking...");
}
}
const flyingBirds: FlyingBird[] = [new Sparrow()];
for (const bird of flyingBirds) {
bird.fly();
}
const walkingBirds: WalkingBird[] = [new Sparrow(), new Kiwi()];
for (const bird of walkingBirds) {
bird.walk();
}
Sekarang Kiwi tidak dipaksa punya method fly(). Abstraksinya lebih jujur terhadap perilaku nyata object.
Keuntungan LSP:
- Inheritance lebih aman
- Class turunan tidak dipaksa melakukan hal yang tidak relevan
- Logic program lebih mudah diprediksi
- Bug karena asumsi parent-child berkurang
4. ISP: Interface Segregation Principle
Definisi singkatnya: client tidak boleh dipaksa bergantung pada method yang tidak mereka gunakan.
Jangan membuat interface yang terlalu besar. Interface yang terlalu besar akan memaksa class untuk mengimplementasikan method yang sebenarnya tidak dibutuhkan.
Contoh yang melanggar ISP:
interface SmartPrinter {
print(document: string): void;
scan(): string;
copy(): string;
}
class ModernPrinter implements SmartPrinter {
print(document: string) {
console.log(`Printing ${document}`);
}
scan() {
return "Scanned document";
}
copy() {
return "Copied document";
}
}
class ClassicPrinter implements SmartPrinter {
print(document: string) {
console.log(`Printing ${document}`);
}
scan() {
throw new Error("Classic printer cannot scan");
}
copy() {
throw new Error("Classic printer cannot copy");
}
}
ClassicPrinter hanya bisa print, tapi dipaksa punya scan() dan copy(). Ini membuat implementasi palsu dan berpotensi error.
Versi yang lebih sesuai ISP:
interface Printable {
print(document: string): void;
}
interface Scannable {
scan(): string;
}
interface Copyable {
copy(): string;
}
class ModernPrinter implements Printable, Scannable, Copyable {
print(document: string) {
console.log(`Printing ${document}`);
}
scan() {
return "Scanned document";
}
copy() {
return "Copied document";
}
}
class ClassicPrinter implements Printable {
print(document: string) {
console.log(`Printing ${document}`);
}
}
Sekarang setiap class cukup memilih interface yang memang dibutuhkan.
Keuntungan ISP:
- Interface lebih kecil dan spesifik
- Implementasi palsu berkurang
- Kode lebih fleksibel
- Class tidak dipaksa punya method yang tidak relevan
5. DIP: Dependency Inversion Principle
Definisi singkatnya:
- High-level module tidak boleh bergantung langsung pada low-level module
- Keduanya sebaiknya bergantung pada abstraksi
Dalam praktiknya, class utama sebaiknya bergantung pada interface, bukan langsung ke class konkret.
Contoh yang melanggar DIP:
class DokuPayment {
pay(amount: number) {
console.log(`Paying ${amount} with Doku`);
}
}
class PaymentController {
private readonly payment = new DokuPayment();
checkout(amount: number) {
this.payment.pay(amount);
}
}
PaymentController terkunci ke DokuPayment. Kalau nanti ingin pindah ke Midtrans, Stripe, atau payment gateway lain, kita harus mengedit controller.
Versi yang lebih sesuai DIP:
interface PaymentGateway {
pay(amount: number): void;
}
class DokuPayment implements PaymentGateway {
pay(amount: number) {
console.log(`Paying ${amount} with Doku`);
}
}
class MidtransPayment implements PaymentGateway {
pay(amount: number) {
console.log(`Paying ${amount} with Midtrans`);
}
}
class PaymentController {
constructor(private readonly paymentGateway: PaymentGateway) {}
checkout(amount: number) {
this.paymentGateway.pay(amount);
}
}
const dokuController = new PaymentController(new DokuPayment());
const midtransController = new PaymentController(new MidtransPayment());
dokuController.checkout(100_000);
midtransController.checkout(250_000);
Sekarang PaymentController tidak peduli payment gateway mana yang dipakai. Selama implementasinya mengikuti PaymentGateway, controller bisa menggunakannya.
Keuntungan DIP:
- Coupling lebih rendah
- Implementasi bisa diganti lebih mudah
- Testing lebih enak karena dependency bisa di-mock
- Service bisa dibuat lebih plug-and-play
Kesimpulan
SOLID bukan aturan kaku yang harus dipaksakan ke semua kode. Anggap saja SOLID sebagai alat bantu berpikir. Ketika kode mulai sulit diubah, sulit dites, atau terlalu banyak tanggung jawab dalam satu tempat, prinsip-prinsip ini bisa membantu kita mencari desain yang lebih sehat.
Di contoh-contoh tadi, TypeScript membantu membuat ide SOLID terasa lebih konkret: mana kontraknya, mana implementasinya, dan bagian mana yang sebaiknya tidak saling bergantung langsung.
Kalau diringkas:
- SRP: satu class, satu alasan utama untuk berubah
- OCP: tambah fitur dengan menambah implementasi, bukan mengacak-acak logic lama
- LSP: subtype harus aman menggantikan parent type
- ISP: interface kecil lebih baik daripada interface gemuk
- DIP: bergantunglah pada abstraksi, bukan detail konkret
Mudah-mudahan artikel ini membantu kamu melihat SOLID dengan lebih praktis. Kode yang baik bukan cuma kode yang jalan, tapi kode yang tetap nyaman disentuh ketika project makin besar.
Artikel Terkait
Lanjutkan membaca topik yang masih satu konteks.
Obat untuk Kode yang Sakit: KISS, DRY, SoC, YAGNI, SOLID
Kode yang 'sakit' bisa membuat project sulit dipelihara dan dikembangkan. Prinsip seperti KISS, DRY, SOC, YAGNI, dan SOLID adalah 'obat' untuk menyembuhkan kode agar lebih bersih, modular, dan efisien. Simak bagaimana menerapkannya dalam pengembangan software!
Domain-Driven Design: Mengatur Struktur Codebase Besar
Gunakan Domain-Driven Design untuk mengatur codebase besar dengan ubiquitous language, bounded context, entity, value object, aggregate, repository, dan domain service.
CQRS Pattern: Pisahkan Read dan Write untuk Sistem Skalabel
Gunakan CQRS untuk memisahkan read dan write model, meningkatkan skalabilitas, menyederhanakan query kompleks, dan memperjelas boundary.
OpenClaw AI Assistant: Command Center Personal Local-First
Kenali OpenClaw sebagai AI assistant local-first dan command center personal untuk aplikasi chat, workflow, dan otomasi multi-channel.