0
0
Lập trình
NM

Từ Junior đến Pro: Làm Chủ Thiết Kế Code với S.O.L.I.D.

Đăng vào 1 tuần trước

• 10 phút đọc

Giới thiệu

Trong thế giới phát triển phần mềm, khái niệm về việc xây dựng "code tốt" thường xuyên được nhắc đến. Nhưng thực sự, "code tốt" nghĩa là gì? Liệu đó có phải là code chạy nhanh? Code không có lỗi? Hay thực sự là một khái niệm sâu hơn? Code tốt, về bản chất, là code dễ hiểu, dễ bảo trì và mở rộng. Nó không sụp đổ trước áp lực của các tính năng mới hoặc những thay đổi bất ngờ.

Nguyên tắc SOLID sẽ giúp chúng ta hiểu rõ hơn về điều này. Hơn cả một tập hợp các quy tắc, chúng là một triết lý xây dựng phần mềm linh hoạt, bền bỉ và sẵn sàng cho tương lai. Trong bài viết này, chúng ta sẽ khám phá từng nguyên tắc SOLID - Bao gồm: Nguyên tắc Trách nhiệm Đơn (Single Responsibility), Nguyên tắc Mở/Đóng (Open/Closed), Nguyên tắc Thay thế Liskov (Liskov Substitution), Nguyên tắc Phân chia Giao diện (Interface Segregation), và Nguyên tắc Đảo ngược Phụ thuộc (Dependency Inversion) - và xem cách chúng có thể biến đổi cách bạn viết code. Hãy chuẩn bị để vượt ra ngoài lý thuyết và khám phá cách những nguyên tắc này có thể cứu bạn khỏi những buổi tối debug kéo dài và giúp bạn trở thành một lập trình viên tự tin, có khả năng hơn.

1. Nguyên Tắc Trách Nhiệm Đơn (Single Responsibility Principle)

Lớp hoặc hàm của bạn chỉ có một TRÁCH NHIỆM và chỉ có MỘT LÝ DO ĐỂ THAY ĐỔI.

Giả sử chúng ta có một lớp NotificationService, có nhiệm vụ gửi thông báo cho người dùng. Hãy xem ví dụ sau:

javascript Copy
// ❌ XẤU: Lớp này có hai trách nhiệm
class NotificationService {
  sendMessage(user, message) {
    // 1. Trách nhiệm: Định dạng thông điệp
    const formattedMessage = `Xin chào ${user.name}, Tin nhắn: ${message}`;

    // 2. Trách nhiệm: Gửi thông báo
    console.log(`Đang gửi: ${formattedMessage} tới người dùng: ${user.name}`);
  }
}

Ở đây, lớp này có hai trách nhiệm chính: Định dạng thông điệp và Gửi thông điệp đã được định dạng. Nếu chúng ta cần thay đổi cách định dạng thông điệp, chúng ta sẽ phải sửa đổi lớp NotificationService.

javascript Copy
// ✅ TỐT: Mỗi lớp có một nhiệm vụ rõ ràng
class MessageFormatter {
  formatMessage(user, message) {
    return `Xin chào ${user.name}, Tin nhắn: ${message}`;
  }
}

class NotificationService {
  constructor(formatter) {
    this.messageFormatter = formatter;
  }

  sendMessage(user, message) {
    const formattedMessage = this.messageFormatter.formatMessage(user, message);
    console.log(`Đang gửi: ${formattedMessage} tới người dùng: ${user.name}`);
  }
}

Nếu chúng ta cần thay đổi cách định dạng thông điệp, chỉ cần sửa đổi lớp MessageFormatter mà không cần thay đổi lớp NotificationService. Điều này giảm thiểu rủi ro và đạt được "S" trong SOLID.

Thực hành tốt

  • Tách biệt nhiệm vụ của các lớp để tránh rủi ro khi sửa đổi.
  • Kiểm tra các thay đổi trong một lớp mà không ảnh hưởng đến các lớp khác.

Những cạm bẫy phổ biến

  • Đặt quá nhiều trách nhiệm vào một lớp duy nhất.
  • Không quản lý các thay đổi, có thể dẫn đến lỗi không mong muốn.

2. Nguyên Tắc Mở/Đóng (Open/Closed Principle)

Lớp hoặc hàm của bạn nên mở cho việc mở rộng, nhưng đóng cho việc sửa đổi.

Điều này có nghĩa là bạn nên có khả năng thêm tính năng mới mà không cần thay đổi mã hiện tại.

javascript Copy
// ❌ XẤU: Chúng ta phải sửa đổi lớp này cho từng loại thông báo mới
class NotificationService {
  sendMessage(type, message) {
    switch (type) {
      case "email":
        console.log(`Gửi Email: ${message}`);
        break;
      case "sms":
        console.log(`Gửi SMS: ${message}`);
        break;
      // 😱 Chúng ta sẽ phải thêm một 'case' mới cho thông báo Push!
    }
  }
}

Khi muốn thêm thông báo Push, chúng ta cần phải sửa đổi lớp NotificationService, điều này có thể dẫn đến rủi ro về lỗi. Hãy xem cách chúng ta có thể cải thiện điều này:

javascript Copy
// ✅ TỐT: Chúng ta có thể thêm các thông báo mới mà không thay đổi mã hiện tại
class EmailNotifier {
  send(message) {
    console.log(`Gửi Email: ${message}`);
  }
}

class SmsNotifier {
  send(message) {
    console.log(`Gửi SMS: ${message}`);
  }
}

class PushNotifier {
  send(message) {
    console.log(`Gửi Thông báo Push: ${message}`);
  }
}

class NotificationService {
  send(notifier, message) {
    notifier.send(message);
  }
}

Giờ đây, việc thêm thông báo Push hoặc bất kỳ loại thông báo nào khác không cần thay đổi lớp NotificationService. Điều này giúp mã sạch hơn và dễ bảo trì hơn.

Thực hành tốt

  • Thiết kế các lớp để có thể mở rộng mà không cần sửa đổi mã gốc.
  • Sử dụng các mẫu thiết kế như Strategy Pattern để quản lý các loại thông báo.

Những cạm bẫy phổ biến

  • Thay đổi mã hiện tại để thêm tính năng mới.
  • Quá phụ thuộc vào logic trong một lớp, dẫn đến mã khó bảo trì.

3. Nguyên Tắc Thay Thế Liskov (Liskov Substitution Principle)

Mọi lớp con đều nên có thể thay thế cho lớp cha của chúng mà không làm hỏng chương trình.

Các lớp con cần mở rộng chức năng của lớp cha chứ không phải thu hẹp chúng. Hãy xem ví dụ sau:

javascript Copy
// ❌ XẤU: Lớp con phá vỡ hợp đồng của lớp cha
class Notification {
  constructor(recipient) {
    this.recipient = recipient;
  }

  send(message) {
    console.log(`Gửi "${message}" tới ${this.recipient}`);
  }
}

class EmailNotification extends Notification {}

class GuestNotification extends Notification {
  constructor() {
    super("");
  }

  send(message) {
    throw new Error("Khách không thể nhận thông báo.");
  }
}

function sendWelcomeMessage(notification) {
  notification.send("Chào mừng!");
}

sendWelcomeMessage(new EmailNotification("dev@example.com"));
sendWelcomeMessage(new GuestNotification()); // 💥 CRASH!

Để giải quyết tình huống này, chúng ta cần nghĩ lại thiết kế của mình:

javascript Copy
// ✅ TỐT: Tất cả các lớp con đều có thể thay thế an toàn
class Notification {
  send(message) {
    throw new Error("Phương thức 'send' phải được cài đặt bởi các lớp con.");
  }
}

class EmailNotification extends Notification {
  constructor(recipient) {
    super();
    this.recipient = recipient;
  }

  send(message) {
    console.log(`Gửi Email "${message}" tới ${this.recipient}`);
  }
}

class PushNotification extends Notification {
  constructor(deviceId) {
    super();
    this.deviceId = deviceId;
  }

  send(message) {
    console.log(`Gửi Thông báo Push "${message}" tới thiết bị ${this.deviceId}`);
  }
}

function sendWelcomeMessage(notification) {
  notification.send("Chào mừng!");
}

sendWelcomeMessage(new EmailNotification("dev@example.com"));
sendWelcomeMessage(new PushNotification("device-123"));

Bây giờ, bất kỳ hàm nào mong đợi một thông báo sẽ hoạt động hoàn hảo với tất cả các loại con của nó.

Thực hành tốt

  • Thiết kế các lớp cha và con sao cho chúng có thể thay thế cho nhau mà không gây ra lỗi.
  • Đảm bảo rằng các lớp con mở rộng chức năng mà không làm hỏng chức năng của lớp cha.

Những cạm bẫy phổ biến

  • Thiết kế các lớp con làm hỏng chức năng của lớp cha.
  • Không khai báo rõ ràng các phương thức mà lớp con cần phải cài đặt.

4. Nguyên Tắc Phân Chia Giao Diện (Interface Segregation Principle)

Giữ cho giao diện của bạn gọn nhẹ và dễ sử dụng.

Các lớp khách hàng hoặc mới không nên bị buộc phải triển khai các phương thức hoặc thuộc tính mà họ không sử dụng.

Hãy xem ví dụ sau:

javascript Copy
// ❌ XẤU: Giao diện này quá "nặng"
interface INotifier {
  sendEmail(message: string): void;
  sendSms(message: string): void;
  sendPushNotification(message: string): void;
}

class PushNotifier implements INotifier {
  sendEmail(message: string) {
    // Tôi không làm điều này! 🤷‍♂️
  }

  sendSms(message: string) {
    // Cũng không làm điều này!
  }

  sendPushNotification(message: string) {
    console.log(`Gửi Thông báo Push: ${message}`);
  }
}

Để giải quyết tình huống này, chúng ta nên chia nhỏ giao diện thành các giao diện nhỏ hơn, mỗi giao diện chỉ chứa các phương thức cần thiết:

javascript Copy
// ✅ TỐT: Các giao diện nhỏ, tập trung hơn là tốt hơn
interface IEmailNotifier {
  sendEmail(message: string): void;
}

interface ISmsNotifier {
  sendSms(message: string): void;
}

interface IPushNotifier {
  sendPushNotification(message: string): void;
}

class PushNotifier implements IPushNotifier {
  sendPushNotification(message: string) {
    console.log(`Gửi Thông báo Push: ${message}`);
  }
}

class AllInOneNotifier implements IEmailNotifier, ISmsNotifier {
  sendEmail(message: string) {
    console.log(`Gửi Email: ${message}`);
  }

  sendSms(message: string) {
    console.log(`Gửi SMS: ${message}`);
  }
}

Cách tiếp cận này giúp mã của bạn sạch hơn và dễ hiểu hơn, đồng thời ngăn chặn việc triển khai các phương thức không cần thiết.

Thực hành tốt

  • Thiết kế giao diện nhỏ gọn để giảm thiểu việc phải triển khai các phương thức không cần thiết.
  • Tạo các giao diện riêng biệt cho từng loại dịch vụ.

Những cạm bẫy phổ biến

  • Thiết kế giao diện quá phức tạp với quá nhiều phương thức không cần thiết.
  • Không sử dụng các giao diện nhỏ hơn cho các dịch vụ riêng biệt.

5. Nguyên Tắc Đảo Ngược Phụ Thuộc (Dependency Inversion Principle)

Mục tiêu của nguyên tắc này là tách rời các mô-đun.

Các mô-đun hoặc lớp cấp cao không nên phụ thuộc vào các mô-đun hoặc lớp cấp thấp, mà thay vào đó, cả hai nên phụ thuộc vào các trừu tượng (hoặc giao diện).

Giả sử bạn có NotificationService tạo một thể hiện của EmailService. Điều này có nghĩa là NotificationService bị phụ thuộc chặt chẽ vào EmailService. Trong tương lai, nếu bạn muốn triển khai một dịch vụ SMS mới, bạn sẽ cần phải sửa đổi lớp NotificationService, điều này có thể dẫn đến lỗi liên quan đến mã gửi email đã hoạt động tốt.

javascript Copy
// ❌ XẤU: Mô-đun cấp cao phụ thuộc trực tiếp vào mô-đun cấp thấp
class EmailService {
  send(message) {
    console.log(`Gửi Email: ${message}`);
  }
}

class NotificationService {
  constructor() {
    this.emailService = new EmailService();
  }

  notify(message) {
    this.emailService.send(message);
  }
}

Thay vì tạo trực tiếp một thể hiện của EmailService, chúng ta sẽ tạo một giao diện hoặc lớp trừu tượng định nghĩa cách thức gửi thông báo:

javascript Copy
// ✅ TỐT: Cả hai mô-đun đều phụ thuộc vào một trừu tượng
interface IMessageSender {
  send(message: string): void;
}

class EmailService implements IMessageSender {
  send(message: string) {
    console.log(`Gửi Email: ${message}`);
  }
}

class SmsService implements IMessageSender {
  send(message: string) {
    console.log(`Gửi SMS: ${message}`);
  }
}

class NotificationService {
  constructor(sender: IMessageSender) {
    this.sender = sender;
  }

  notify(message: string) {
    this.sender.send(message);
  }
}

const emailNotifier = new NotificationService(new EmailService());
const smsNotifier = new NotificationService(new SmsService());

emailNotifier.notify("Xin chào qua Email!");
smsNotifier.notify("Xin chào qua SMS!");

Điều này đảm bảo rằng mã của chúng ta sẵn sàng cho việc mở rộng. Ngay cả khi cần giới thiệu một loại thông báo mới, chúng ta chỉ cần sử dụng trừu tượng để tạo nó mà không cần sửa đổi trong lớp NotificationService.

Thực hành tốt

  • Tách rời các mô-đun để dễ dàng mở rộng và bảo trì.
  • Sử dụng các giao diện để quản lý sự phụ thuộc giữa các lớp.

Những cạm bẫy phổ biến

  • Thiết kế các lớp phụ thuộc chặt chẽ vào nhau, gây khó khăn trong việc bảo trì.
  • Không sử dụng trừu tượng để tách rời các mô-đun.

Kết luận

Bạn đã hoàn thành hành trình khám phá năm nguyên tắc SOLID. Từ Nguyên tắc Trách nhiệm Đơn, dạy chúng ta giữ cho các lớp của mình tập trung, đến Nguyên tắc Đảo ngược Phụ thuộc, giúp chúng ta thoát khỏi sự phụ thuộc cứng nhắc, những nguyên tắc này không chỉ là lý thuyết mà còn là bộ công cụ thực tiễn để xây dựng phần mềm mạnh mẽ hơn, dễ bảo trì hơn và sẵn sàng thích ứng với những yêu cầu thay đổi của dự án.

Việc áp dụng các nguyên tắc SOLID có thể là thách thức ngay từ đầu, nhưng những lợi ích lâu dài là không thể phủ nhận. Giảm thiểu nợ kỹ thuật, dễ dàng hơn trong việc debug và khả năng tự tin thêm tính năng mới mà không làm hỏng các tính năng hiện tại chỉ là một số phần thưởng. Vậy lần sau khi bạn viết một dòng code, hãy tự hỏi: Nó có SOLID không? Tương lai của bạn - và đội ngũ của bạn - sẽ cảm ơn bạn vì điều đó.

Cảm ơn bạn đã đọc bài viết này và hãy cho tôi biết ý kiến của bạn trong phần bình luận nhé!

Gợi ý câu hỏi phỏng vấn
Không có dữ liệu

Không có dữ liệu

Bài viết được đề xuất
Bài viết cùng tác giả

Bình luận

Chưa có bình luận nào

Chưa có bình luận nào