10 min read

SOLID: Nền Tảng Cho Code Xịn, Mịn, Dễ Bảo Trì

SOLID - “Bộ Quy Tắc Vàng” Của Dân Lập Trình Hướng Đối Tượng

SOLID là một từ viết tắt đại diện cho 5 nguyên tắc thiết kế hướng đối tượng (Object-Oriented Design - OOD) được “đúc kết” bởi “bậc thầy” Robert C. Martin (Uncle Bob). Những nguyên tắc này được xem như “kim chỉ nam” giúp các lập trình viên viết ra code “sạch”, dễ đọc, dễ bảo trì, dễ mở rộng và tái sử dụng. Bài viết này sẽ cùng bạn khám phá 5 nguyên tắc “thần thánh” này và cách áp dụng chúng trong thực tế.

SOLID Là Gì?

SOLID là viết tắt của 5 nguyên tắc sau:

  • S - Single Responsibility Principle (Nguyên tắc Đơn Trách Nhiệm)
  • O - Open/Closed Principle (Nguyên tắc Mở/Đóng)
  • L - Liskov Substitution Principle (Nguyên tắc Thay Thế Liskov)
  • I - Interface Segregation Principle (Nguyên tắc Phân Tách Interface)
  • D - Dependency Inversion Principle (Nguyên tắc Đảo Ngược Phụ Thuộc)

Giải Mã 5 Nguyên Tắc SOLID

1. Single Responsibility Principle (SRP) - Mỗi Class, Một Trách Nhiệm

  • Nội dung: Một class chỉ nên chịu trách nhiệm cho một và chỉ một mục đích (hoặc chức năng) duy nhất.
  • Tại sao? Giúp code dễ hiểu, dễ bảo trì, dễ kiểm thử và tái sử dụng. Khi có thay đổi, bạn chỉ cần chỉnh sửa ở một nơi, giảm thiểu rủi ro ảnh hưởng đến các phần khác của hệ thống.
  • Ví dụ:
// Không tốt - Class User đảm nhận quá nhiều trách nhiệm
class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  saveUserToDatabase() {
    // Code to save user to database
    console.log(`Saving user ${this.name} to database...`);
  }

  sendEmail(message) {
    // Code to send email
    console.log(`Sending email to ${this.email}: ${message}`);
  }
}
// Tốt hơn - Tách biệt trách nhiệm
class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
}

class UserDatabase {
  save(user) {
    // Code to save user to database
    console.log(`Saving user ${user.name} to database...`);
  }
}

class EmailService {
  send(email, message) {
    // Code to send email
    console.log(`Sending email to ${email}: ${message}`);
  }
}

const user = new User("John Doe", "john.doe@example.com");
const userDb = new UserDatabase();
const emailService = new EmailService();

userDb.save(user);
emailService.send(user.email, "Welcome!");
  • Giải thích: Thay vì để class User “ôm đồm” cả việc lưu vào database và gửi email, chúng ta tách ra thành các class riêng biệt: User, UserDatabase, và EmailService. Mỗi class chỉ tập trung vào một nhiệm vụ duy nhất.

2. Open/Closed Principle (OCP) - Mở Để Mở Rộng, Đóng Để Sửa Đổi

  • Nội dung: Một class nên “mở” cho việc mở rộng (có thể thêm tính năng mới), nhưng “đóng” với việc sửa đổi (không cần sửa code cũ khi thêm tính năng mới).
  • Tại sao? Giúp code dễ bảo trì, giảm thiểu rủi ro khi thêm tính năng mới. Bạn không cần phải “đập đi xây lại” code cũ, mà chỉ cần “gắn” thêm các “module” mới.
  • Ví dụ:
// Không tốt - Sửa đổi class cũ khi thêm tính năng mới
class Order {
  constructor(items, paymentMethod) {
    this.items = items;
    this.paymentMethod = paymentMethod;
  }

  processOrder() {
    if (this.paymentMethod === "creditCard") {
      // Process with credit card
      console.log("Processing with credit card...");
    } else if (this.paymentMethod === "paypal") {
      // Process with PayPal
      console.log("Processing with PayPal...");
    }
    // Nếu muốn thêm phương thức thanh toán mới, phải sửa lại class Order
  }
}
// Tốt hơn - Mở rộng bằng cách kế thừa hoặc sử dụng interface
class Order {
  constructor(items) {
    this.items = items;
  }

  processPayment(paymentProcessor) {
    paymentProcessor.process(this.items);
  }
}

class CreditCardProcessor {
  process(items) {
    // Process with credit card
    console.log("Processing with credit card...");
  }
}

class PayPalProcessor {
  process(items) {
    // Process with PayPal
    console.log("Processing with PayPal...");
  }
}

const order = new Order(["Product 1", "Product 2"]);
const creditCardProcessor = new CreditCardProcessor();
const payPalProcessor = new PayPalProcessor();

order.processPayment(creditCardProcessor); // Output: Processing with credit card...
order.processPayment(payPalProcessor); // Output: Processing with PayPal...
  • Giải thích: Thay vì “nhồi nhét” tất cả các phương thức thanh toán vào class Order, chúng ta tạo ra các class riêng biệt cho từng phương thức thanh toán (CreditCardProcessor, PayPalProcessor). Class Order chỉ cần gọi phương thức process của đối tượng paymentProcessor tương ứng. Khi muốn thêm phương thức thanh toán mới, chúng ta chỉ cần tạo class mới mà không cần sửa đổi class Order.

3. Liskov Substitution Principle (LSP) - “Con” Có Thể Thay Thế “Cha”

  • Nội dung: Các đối tượng của class con (subclass) phải có thể thay thế được cho các đối tượng của class cha (superclass) mà không làm thay đổi tính đúng đắn của chương trình.
  • Tại sao? Đảm bảo tính kế thừa được sử dụng đúng cách, giúp code linh hoạt và dễ bảo trì hơn.
  • Ví dụ:
// Không tốt - Class Square vi phạm LSP khi thay đổi hành vi của Rectangle
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  setWidth(width) {
    this.width = width;
    this.height = width;
  }

  setHeight(height) {
    this.width = height;
    this.height = height;
  }
}

function testRectangle(rect) {
  rect.setWidth(5);
  rect.setHeight(4);
  console.log(rect.getArea()); // Expected: 20
}

const rectangle = new Rectangle(2, 3);
const square = new Square(2, 2);

testRectangle(rectangle); // Output: 20
testRectangle(square); // Output: 16 (Sai)
// Tốt hơn - Sử dụng composition thay vì inheritance
class Shape {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }
  getArea() {
    return this.width * this.height;
  }
}

class Rectangle {
  constructor(width, height) {
    this.shape = new Shape(width, height);
  }

  getArea() {
    return this.shape.getArea();
  }
}

class Square {
  constructor(side) {
    this.shape = new Shape(side, side);
  }
  getArea() {
    return this.shape.getArea();
  }
}

function testShapeArea(shape) {
  console.log(shape.getArea());
}

const rectangle = new Rectangle(5, 4);
const square = new Square(5);

testShapeArea(rectangle); // Output: 20
testShapeArea(square); // Output: 25
  • Giải thích: Trong ví dụ đầu tiên, Square kế thừa từ Rectangle, nhưng khi thay đổi width hoặc height, nó lại thay đổi luôn cả hai cạnh, làm sai lệch tính toán diện tích. Trong ví dụ thứ hai, thay vì kế thừa, chúng ta sử dụng composition (bao hàm) bằng cách tạo ra instance của Shape bên trong.

4. Interface Segregation Principle (ISP) - “Thà” Nhiều Interface Nhỏ Còn Hơn Một Interface “To”

  • Nội dung: Thay vì dùng một interface “to”, “hầm bà lằng”, hãy tách nó thành nhiều interface nhỏ, với các phương thức cụ thể cho từng nhóm đối tượng sử dụng.
  • Tại sao? Giúp các class không bị phụ thuộc vào những phương thức mà chúng không sử dụng, giảm thiểu sự “rối rắm” và tăng tính linh hoạt.
  • Ví dụ:
// Không tốt - Interface Worker "quá tải"
class Worker {
  work() {
    throw new Error("Not implemented");
  }

  eat() {
    throw new Error("Not implemented");
  }
}

class HumanWorker extends Worker {
  work() {
    console.log("Human working...");
  }

  eat() {
    console.log("Human eating...");
  }
}

class RobotWorker extends Worker {
  work() {
    console.log("Robot working...");
  }

  eat() {
    // Robot không cần ăn!
  }
}
// Tốt hơn - Tách thành các interface nhỏ hơn
class Workable {
  work() {
    throw new Error("Not implemented");
  }
}

class Eatable {
  eat() {
    throw new Error("Not implemented");
  }
}

class HumanWorker extends Workable, Eatable {
  work() {
    console.log("Human working...");
  }

  eat() {
    console.log("Human eating...");
  }
}

class RobotWorker extends Workable {
  work() {
    console.log("Robot working...");
  }
}
  • Giải thích: Thay vì “nhồi nhét” cả workeat vào interface Worker, chúng ta tách thành hai interface WorkableEatable. RobotWorker chỉ cần implement Workable, còn HumanWorker implement cả hai.

5. Dependency Inversion Principle (DIP) - Phụ Thuộc Vào Trừu Tượng, Không Phụ Thuộc Vào Cụ Thể

  • Nội dung:
    • Các module cấp cao (high-level modules) không nên phụ thuộc vào các module cấp thấp (low-level modules). Cả hai nên phụ thuộc vào abstraction (interface hoặc abstract class).
    • Abstraction không nên phụ thuộc vào chi tiết (details). Chi tiết nên phụ thuộc vào abstraction.
  • Tại sao? Giảm sự phụ thuộc lẫn nhau giữa các module, giúp code linh hoạt, dễ bảo trì và dễ mở rộng hơn.
  • Ví dụ:
// Không tốt - Module cấp cao phụ thuộc trực tiếp vào module cấp thấp
class LightBulb {
  turnOn() {
    console.log("LightBulb: turned on...");
  }

  turnOff() {
    console.log("LightBulb: turned off...");
  }
}

class Switch {
  constructor() {
    this.bulb = new LightBulb();
    this.on = false;
  }

  press() {
    this.on = !this.on;
    if (this.on) {
      this.bulb.turnOn();
    } else {
      this.bulb.turnOff();
    }
  }
}

const lightSwitch = new Switch();
lightSwitch.press(); // Output: LightBulb: turned on...
lightSwitch.press(); // Output: LightBulb: turned off...
// Tốt hơn - Phụ thuộc vào abstraction (interface)
class Switchable {
  turnOn() {
    throw new Error("Not implemented");
  }

  turnOff() {
    throw new Error("Not implemented");
  }
}

class LightBulb extends Switchable {
  turnOn() {
    console.log("LightBulb: turned on...");
  }

  turnOff() {
    console.log("LightBulb: turned off...");
  }
}

class Switch {
  constructor(device) {
    this.device = device;
    this.on = false;
  }

  press() {
    this.on = !this.on;
    if (this.on) {
      this.device.turnOn();
    } else {
      this.device.turnOff();
    }
  }
}

const bulb = new LightBulb();
const lightSwitch = new Switch(bulb);
lightSwitch.press(); // Output: LightBulb: turned on...
lightSwitch.press(); // Output: LightBulb: turned off...
  • Giải thích: Thay vì để Switch phụ thuộc trực tiếp vào LightBulb, chúng ta tạo ra interface Switchable. Switch giờ đây phụ thuộc vào Switchable, và LightBulb implement Switchable. Điều này giúp chúng ta dễ dàng thay thế LightBulb bằng một thiết bị khác (ví dụ: Fan) mà không cần sửa đổi Switch.

Kết Luận

SOLID không phải là “luật”, mà là “nguyên tắc” định hướng. Việc áp dụng SOLID một cách linh hoạt và phù hợp với từng trường hợp cụ thể sẽ giúp bạn tạo ra những dòng code “xịn, mịn”, dễ bảo trì, dễ mở rộng và tái sử dụng. Hãy xem SOLID như những “bí kíp” quý giá, và rèn luyện thường xuyên để trở thành một “cao thủ” code thực thụ nhé!