Prinsip SOLID: Fondasi Kode Bersih, Fleksibel, dan Mudah Dirawat di Aplikasi Web Modern
Sebagai developer web, kita seringkali dihadapkan pada tantangan untuk menulis kode yang tidak hanya berfungsi dengan baik, tetapi juga mudah dipahami, diperluas, dan dirawat seiring berjalannya waktu. Kode yang “berfungsi” namun sulit diubah adalah resep untuk technical debt yang menumpuk. Di sinilah Prinsip SOLID hadir sebagai panduan fundamental.
Prinsip SOLID adalah akronim dari lima prinsip desain berorientasi objek yang diperkenalkan oleh Robert C. Martin (Uncle Bob):
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
Menerapkan prinsip-prinsip ini akan membantu kita membangun sistem yang lebih maintainable, scalable, dan testable. Mari kita selami satu per satu!
1. Single Responsibility Principle (SRP): Satu Alasan untuk Berubah
📌 Inti Prinsip: Sebuah kelas, modul, atau fungsi harus memiliki hanya satu alasan untuk berubah.
Ini adalah prinsip yang paling sering disalahpahami. Banyak yang mengira “satu tanggung jawab” berarti “satu fungsi,” padahal sebenarnya lebih tentang satu aktor atau entitas bisnis yang memiliki alasan untuk meminta perubahan.
Analogi: Bayangkan sebuah pisau Swiss Army. Ia bisa melakukan banyak hal (pisau, obeng, gunting). Tapi jika Anda hanya butuh memotong, pisau dapur yang fokus pada satu tugas akan lebih efisien dan mudah digunakan. Jika Anda ingin pisau Anda lebih tajam, Anda tidak perlu khawatir tentang bagaimana itu akan mempengaruhi obengnya.
❌ Contoh Buruk (Pelanggaran SRP):
class UserService {
private db: Database;
private emailService: EmailService;
private logger: Logger;
constructor(db: Database, emailService: EmailService, logger: Logger) {
this.db = db;
this.emailService = emailService;
this.logger = logger;
}
async registerUser(userData: any) {
// 1. Validasi data pengguna
if (!userData.email || !userData.password) {
this.logger.error("Invalid user data for registration.");
throw new Error("Invalid user data.");
}
// 2. Simpan pengguna ke database
const user = await this.db.saveUser(userData);
this.logger.info(`User ${user.email} registered.`);
// 3. Kirim email selamat datang
await this.emailService.sendWelcomeEmail(user.email);
this.logger.info(`Welcome email sent to ${user.email}.`);
return user;
}
// Metode lain untuk mengelola pengguna, dll.
}
Dalam contoh di atas, UserService memiliki banyak alasan untuk berubah:
- Jika logika validasi berubah.
- Jika cara penyimpanan pengguna ke database berubah.
- Jika cara pengiriman email berubah (misalnya, ganti penyedia email).
- Jika format log berubah.
✅ Contoh Baik (Menerapkan SRP):
class UserValidator {
validateRegistration(userData: any): boolean {
if (!userData.email || !userData.password) {
throw new Error("Invalid user data.");
}
return true;
}
}
class UserRepository {
constructor(private db: Database) {}
async saveUser(userData: any): Promise<User> {
// Logika penyimpanan ke database
const user = await this.db.insert('users', userData);
return user;
}
}
class UserService {
constructor(
private validator: UserValidator,
private userRepository: UserRepository,
private emailService: EmailService,
private logger: Logger
) {}
async registerUser(userData: any) {
this.validator.validateRegistration(userData);
const user = await this.userRepository.saveUser(userData);
this.logger.info(`User ${user.email} registered.`);
await this.emailService.sendWelcomeEmail(user.email);
this.logger.info(`Welcome email sent to ${user.email}.`);
return user;
}
}
💡 Dengan memecah tanggung jawab, jika ada perubahan pada logika email, Anda hanya perlu mengubah EmailService, bukan UserService secara keseluruhan. Ini membuat kode lebih mudah diuji, dibaca, dan diubah.
2. Open/Closed Principle (OCP): Buka untuk Ekstensi, Tutup untuk Modifikasi
🎯 Inti Prinsip: Entitas perangkat lunak (kelas, modul, fungsi, dll.) harus terbuka untuk ekstensi, tetapi tertutup untuk modifikasi.
Ini berarti Anda harus bisa menambahkan fungsionalitas baru ke sistem tanpa mengubah kode yang sudah ada dan telah teruji.
Analogi: Bayangkan Anda memiliki sebuah smartphone. Anda bisa menginstal aplikasi baru (ekstensi) tanpa harus membuka dan memodifikasi hardware atau firmware ponsel itu sendiri.
❌ Contoh Buruk (Pelanggaran OCP):
Misalnya Anda memiliki sistem pemrosesan pembayaran dan setiap kali ada metode pembayaran baru, Anda harus mengubah kelas PaymentProcessor.
class PaymentProcessor {
processPayment(type: 'credit_card' | 'paypal' | 'bank_transfer', amount: number) {
if (type === 'credit_card') {
// Logika pembayaran kartu kredit
console.log(`Processing credit card payment of ${amount}`);
} else if (type === 'paypal') {
// Logika pembayaran PayPal
console.log(`Processing PayPal payment of ${amount}`);
} else if (type === 'bank_transfer') {
// Logika pembayaran transfer bank
console.log(`Processing bank transfer payment of ${amount}`);
} else {
throw new Error('Unsupported payment type.');
}
}
}
// Jika ada metode pembayaran baru (misal: GoPay), kita harus mengubah PaymentProcessor ini.
✅ Contoh Baik (Menerapkan OCP):
Kita bisa menggunakan Strategy Pattern dengan interface atau abstract class.
interface PaymentMethod {
process(amount: number): void;
}
class CreditCardPayment implements PaymentMethod {
process(amount: number) {
console.log(`Processing credit card payment of ${amount}`);
}
}
class PayPalPayment implements PaymentMethod {
process(amount: number) {
console.log(`Processing PayPal payment of ${amount}`);
}
}
class BankTransferPayment implements PaymentMethod {
process(amount: number) {
console.log(`Processing bank transfer payment of ${amount}`);
}
}
// Kelas ini "tertutup" untuk modifikasi
class NewPaymentProcessor {
process(paymentMethod: PaymentMethod, amount: number) {
paymentMethod.process(amount);
}
}
// Menambahkan metode pembayaran baru (misal: GoPay) tidak perlu mengubah NewPaymentProcessor
class GoPayPayment implements PaymentMethod {
process(amount: number) {
console.log(`Processing GoPay payment of ${amount}`);
}
}
// Penggunaan
const processor = new NewPaymentProcessor();
processor.process(new CreditCardPayment(), 100);
processor.process(new GoPayPayment(), 50); // Tambahkan GoPay tanpa mengubah NewPaymentProcessor!
💡 Dengan OCP, kode Anda menjadi lebih stabil dan Anda tidak perlu khawatir memperkenalkan bug ke fitur yang sudah ada saat menambahkan fungsionalitas baru.
3. Liskov Substitution Principle (LSP): Subtipe yang Bisa Disubstitusi
✅ Inti Prinsip: Objek dalam sebuah program harus bisa digantikan dengan instance dari subtipe mereka tanpa mengubah kebenaran program tersebut.
Secara sederhana, jika S adalah subtipe dari T, maka object dari tipe T dapat digantikan dengan object dari tipe S tanpa mengubah perilaku yang benar dari program. Ini seringkali tentang memastikan bahwa subkelas tidak merusak ekspektasi perilaku yang ditetapkan oleh kelas induknya.
Analogi: Jika Anda memiliki kelas Burung dengan metode terbang(), dan Anda membuat subkelas BurungUnta yang tidak bisa terbang, maka mengganti Burung dengan BurungUnta di beberapa konteks yang mengharapkan terbang() akan merusak program. BurungUnta harus tetap bisa “disubstitusi” sebagai Burung tanpa masalah, jika tidak, BurungUnta mungkin bukan subtipe yang tepat dari Burung di konteks tersebut.
❌ Contoh Buruk (Pelanggaran LSP):
class Rectangle {
protected _width: number;
protected _height: number;
constructor(width: number, height: number) {
this._width = width;
this._height = height;
}
get width(): number { return this._width; }
set width(value: number) { this._width = value; }
get height(): number { return this._height; }
set height(value: number) { this._height = value; }
area(): number {
return this._width * this._height;
}
}
class Square extends Rectangle {
constructor(side: number) {
super(side, side);
}
// Ini melanggar LSP karena Square mengubah perilaku setter Rectangle
// yang dapat menyebabkan hasil yang tidak terduga di klien yang mengandalkan Rectangle.
set width(value: number) {
this._width = value;
this._height = value; // Mengubah height juga
}
set height(value: number) {
this._width = value; // Mengubah width juga
this._height = value;
}
}
function calculateArea(rect: Rectangle) {
rect.width = 5;
rect.height = 4;
console.log(`Area: ${rect.area()}`); // Expected for Rectangle: 20
}
const myRectangle = new Rectangle(2, 3);
calculateArea(myRectangle); // Output: Area: 20
const mySquare = new Square(3);
calculateArea(mySquare); // Output: Area: 16 (karena setter Square mengubah kedua sisi menjadi 4)
// Ini tidak sesuai ekspektasi jika kita memperlakukannya sebagai Rectangle.
✅ Contoh Baik (Menerapkan LSP):
Fokus pada kontrak perilaku. Jika Square tidak bisa memenuhi kontrak Rectangle tanpa mengubah perilaku, mungkin mereka tidak seharusnya berada dalam hierarki warisan yang sama. Atau, kita bisa mendefinisikan Shape yang lebih abstrak.
interface Shape {
area(): number;
}
class Rectangle implements Shape {
constructor(private _width: number, private _height: number) {}
set width(value: number) { this._width = value; }
set height(value: number) { this._height = value; }
area(): number {
return this._width * this._height;
}
}
class Square implements Shape {
constructor(private _side: number) {}
set side(value: number) { this._side = value; }
area(): number {
return this._side * this._side;
}
}
function printShapeArea(shape: Shape) {
console.log(`Area: ${shape.area()}`);
}
const myRectangle = new Rectangle(5, 4);
printShapeArea(myRectangle); // Output: Area: 20
const mySquare = new Square(4);
printShapeArea(mySquare); // Output: Area: 16
💡 Dalam contoh baik, Rectangle dan Square tidak saling mewarisi, tetapi keduanya mengimplementasikan interface Shape. Ini memastikan bahwa setiap objek yang diperlakukan sebagai Shape akan memiliki metode area() yang berperilaku sesuai harapan.
4. Interface Segregation Principle (ISP): Interface yang Spesifik
✂️ Inti Prinsip: Klien tidak boleh dipaksa untuk bergantung pada interface yang tidak mereka gunakan.
Ini berarti lebih baik memiliki banyak interface kecil yang spesifik daripada satu interface besar yang “gemuk” (fat interface).
Analogi: Bayangkan sebuah remote control TV universal yang memiliki tombol untuk mengontrol pemutar DVD, AC, dan sound system, padahal Anda hanya punya TV. Anda dipaksa memiliki remote dengan banyak tombol yang tidak Anda gunakan. Lebih baik memiliki remote sederhana yang hanya untuk TV.
❌ Contoh Buruk (Pelanggaran ISP):
// Interface "gemuk" yang memiliki terlalu banyak metode
interface IWorker {
work(): void;
eat(): void;
sleep(): void;
manageProjects(): void;
codeReview(): void;
}
class Developer implements IWorker {
work() { console.log('Developer is coding.'); }
eat() { console.log('Developer is eating.'); }
sleep() { console.log('Developer is sleeping.'); }
manageProjects() { /* Developer doesn't manage projects */ throw new Error("Not implemented"); }
codeReview() { console.log('Developer is reviewing code.'); }
}
class ProjectManager implements IWorker {
work() { console.log('Project Manager is coordinating.'); }
eat() { console.log('Project Manager is eating.'); }
sleep() { console.log('Project Manager is sleeping.'); }
manageProjects() { console.log('Project Manager is managing projects.'); }
codeReview() { /* Project Manager doesn't do code review */ throw new Error("Not implemented"); }
}
Baik Developer maupun ProjectManager dipaksa mengimplementasikan metode yang tidak relevan bagi mereka, atau membiarkannya kosong/melempar error.
✅ Contoh Baik (Menerapkan ISP):
interface IHuman {
eat(): void;
sleep(): void;
}
interface ICoder {
code(): void;
codeReview(): void;
}
interface IManager {
manageProjects(): void;
coordinateTeams(): void;
}
class Developer implements IHuman, ICoder {
eat() { console.log('Developer is eating.'); }
sleep() { console.log('Developer is sleeping.'); }
code() { console.log('Developer is coding.'); }
codeReview() { console.log('Developer is reviewing code.'); }
}
class ProjectManager implements IHuman, IManager {
eat() { console.log('Project Manager is eating.'); }
sleep() { console.log('Project Manager is sleeping.'); }
manageProjects() { console.log('Project Manager is managing projects.'); }
coordinateTeams() { console.log('Project Manager is coordinating teams.'); }
}
💡 Sekarang, setiap kelas hanya mengimplementasikan interface yang relevan dengan tanggung jawabnya. Ini membuat kode lebih bersih, lebih mudah dipahami, dan mencegah side effect yang tidak diinginkan.
5. Dependency Inversion Principle (DIP): Bergantung pada Abstraksi
🔄 Inti Prinsip:
- High-level modules tidak boleh bergantung pada low-level modules. Keduanya harus bergantung pada abstraksi.
- Abstraksi tidak boleh bergantung pada detail. Detail harus bergantung pada abstraksi.
Ini adalah prinsip yang sangat kuat dan seringkali dicapai melalui Dependency Injection (DI).
Analogi: Bayangkan sebuah soket listrik di dinding (abstraksi). Peralatan elektronik Anda (detail low-level) bergantung pada soket, bukan sebaliknya. Anda tidak perlu mengubah soket di dinding setiap kali Anda membeli jenis charger baru.
❌ Contoh Buruk (Pelanggaran DIP):
class MySQLDatabase {
connect(): void {
console.log('Connecting to MySQL...');
}
getData(): string {
return 'Data from MySQL';
}
}
class ReportGenerator {
private db: MySQLDatabase; // Bergantung pada implementasi konkret low-level
constructor() {
this.db = new MySQLDatabase(); // Membuat instance langsung
}
generateReport(): void {
this.db.connect();
const data = this.db.getData();
console.log(`Generating report with: ${data}`);
}
}
const generator = new ReportGenerator();
generator.generateReport();
ReportGenerator (modul high-level) secara langsung bergantung pada MySQLDatabase (modul low-level). Jika Anda ingin mengganti database menjadi PostgreSQL atau MongoDB, Anda harus memodifikasi ReportGenerator.
✅ Contoh Baik (Menerapkan DIP):
// 1. Abstraksi (Interface)
interface IDatabase {
connect(): void;
getData(): string;
}
// 2. Modul Low-level bergantung pada Abstraksi
class MySQLDatabase implements IDatabase {
connect(): void {
console.log('Connecting to MySQL...');
}
getData(): string {
return 'Data from MySQL';
}
}
class PostgreSQLDatabase implements IDatabase {
connect(): void {
console.log('Connecting to PostgreSQL...');
}
getData(): string {
return 'Data from PostgreSQL';
}
}
// 3. Modul High-level bergantung pada Abstraksi
class ReportGenerator {
private db: IDatabase; // Bergantung pada abstraksi
// Dependency Injection melalui constructor
constructor(db: IDatabase) {
this.db = db;
}
generateReport(): void {
this.db.connect();
const data = this.db.getData();
console.log(`Generating report with: ${data}`);
}
}
// Penggunaan
const mysqlDb = new MySQLDatabase();
const reportGenerator1 = new ReportGenerator(mysqlDb);
reportGenerator1.generateReport(); // Output: Data from MySQL
const pgDb = new PostgreSQLDatabase();
const reportGenerator2 = new ReportGenerator(pgDb);
reportGenerator2.generateReport(); // Output: Data from PostgreSQL
💡 Dengan DIP, ReportGenerator tidak perlu tahu detail implementasi database. Ia hanya tahu ada sesuatu yang mengimplementasikan IDatabase. Ini membuat ReportGenerator jauh lebih fleksibel, testable (Anda bisa menyuntikkan mock database untuk pengujian), dan tahan terhadap perubahan pada detail low-level.
Kesimpulan
Prinsip SOLID adalah pilar penting dalam membangun arsitektur perangkat lunak yang kokoh, fleksibel, dan mudah dipertahankan. Menerapkan prinsip