Vấn đề

Bạn đã từng đọc về SOLID và cảm thấy… vẫn mơ hồ? Không phải vì bạn không hiểu — mà vì hầu hết các ví dụ trên mạng đều dùng Animal, Shape, hay Employee quá chung chung, không gắn với bài toán thực tế nào cả.

Bài viết này sẽ đi khác một chút. Tôi sẽ dùng các ví dụ gần với công việc thực tế hơn, và tập trung vào việc tại sao mỗi nguyên tắc lại quan trọng — không chỉ là định nghĩa khô khan.

SOLID là gì?

SOLID là tập hợp 5 nguyên tắc thiết kế hướng đối tượng, được Robert C. Martin (Uncle Bob) đúc kết lại. Mục tiêu là viết code dễ bảo trì, dễ mở rộng và ít bug hơn khi dự án phình to.

5 nguyên tắc gồm:

  • S — Single Responsibility Principle (SRP)
  • O — Open/Closed Principle (OCP)
  • L — Liskov Substitution Principle (LSP)
  • I — Interface Segregation Principle (ISP)
  • D — Dependency Inversion Principle (DIP)

S — Single Responsibility Principle

Nguyên tắc

A class should have one, and only one, reason to change.

— Robert C. Martin

Nói đơn giản, một class chỉ nên chịu trách nhiệm cho một nhóm công việc rõ ràng. Khi một class vừa xử lý business logic, vừa gửi email, vừa thao tác database — nó sẽ có nhiều lý do để thay đổi, và đó là lúc code bắt đầu khó bảo trì.

Ví dụ sai

public class OrderService {
    public void placeOrder(Order order) {
        order.setStatus("PLACED");

        String emailBody = "Đơn hàng #" + order.getId() + " đã được đặt.";
        EmailSender.send(order.getCustomerEmail(), emailBody);

        Database.save(order);
    }
}

OrderService đang gánh ba trách nhiệm khác nhau: xử lý nghiệp vụ đặt hàng, gửi email thông báo, và lưu dữ liệu vào database. Điều này tạo ra vấn đề gì?

  • Nếu thay đổi logic gửi email, bạn phải vào sửa OrderService
  • Nếu đổi từ MySQL sang MongoDB, bạn cũng phải sửa OrderService
  • Nếu business rule của việc đặt hàng thay đổi, bạn vẫn sửa đúng file đó

Tức là class này có nhiều hơn một lý do để thay đổi — và đó chính xác là vi phạm SRP.

Ví dụ đúng

public class OrderService {
    private final OrderRepository orderRepository;
    private final EmailNotificationService emailService;

    public OrderService(OrderRepository orderRepository, EmailNotificationService emailService) {
        this.orderRepository = orderRepository;
        this.emailService = emailService;
    }

    public void placeOrder(Order order) {
        order.setStatus("PLACED");
        orderRepository.save(order);
        emailService.sendOrderConfirmation(order);
    }
}

Ở phiên bản này, mỗi thành phần có trách nhiệm rõ ràng:

  • OrderService chỉ điều phối flow đặt hàng
  • OrderRepository chịu trách nhiệm lưu dữ liệu
  • EmailNotificationService chịu trách nhiệm gửi thông báo

Khi cần thay đổi bất kỳ phần nào, bạn biết chính xác cần sửa file nào — không lo sửa một chỗ lại vô tình ảnh hưởng chỗ khác.

O — Open/Closed Principle

Nguyên tắc

Software entities should be open for extension, but closed for modification.

— Bertrand Meyer, phổ biến bởi Robert C. Martin

Một class nên mở để mở rộng (thêm tính năng mới) nhưng đóng để sửa đổi (không phải chỉnh sửa code hiện có). Nguyên tắc này giúp tránh rủi ro khi thêm tính năng mới làm hỏng những thứ đang chạy tốt.

Ví dụ sai

public class DiscountCalculator {
    public double calculate(Order order, String customerType) {
        if (customerType.equals("VIP")) {
            return order.getTotal() * 0.2;
        } else if (customerType.equals("MEMBER")) {
            return order.getTotal() * 0.1;
        }
        return 0;
    }
}

Mỗi khi có thêm loại khách hàng mới — ví dụ PARTNER, STUDENT, hay STAFF — bạn lại phải vào chỉnh sửa trực tiếp DiscountCalculator. Điều này vi phạm OCP vì class này không đóng với sửa đổi.

Ngoài ra, chuỗi if/else càng dài thì càng khó test, khó đọc, và dễ gây bug khi thêm case mới.

Ví dụ đúng

public interface DiscountStrategy {
    double calculate(Order order);
}

public class VipDiscount implements DiscountStrategy {
    public double calculate(Order order) {
        return order.getTotal() * 0.2;
    }
}

public class MemberDiscount implements DiscountStrategy {
    public double calculate(Order order) {
        return order.getTotal() * 0.1;
    }
}

public class PartnerDiscount implements DiscountStrategy {
    public double calculate(Order order) {
        return order.getTotal() * 0.15;
    }
}

public class DiscountCalculator {
    private final DiscountStrategy strategy;

    public DiscountCalculator(DiscountStrategy strategy) {
        this.strategy = strategy;
    }

    public double calculate(Order order) {
        return strategy.calculate(order);
    }
}

Muốn thêm loại discount mới? Chỉ cần tạo thêm một class implement DiscountStrategy — không cần chạm vào DiscountCalculator. Class này giờ mở để mở rộng (thêm implementation mới) nhưng đóng với sửa đổi (không cần sửa code cũ).

L — Liskov Substitution Principle

Nguyên tắc

If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.

— Barbara Liskov

Nói thực tế hơn: nếu bạn có một đoạn code đang nhận vào kiểu T, thì truyền vào một object kiểu S (subclass của T) cũng phải hoạt động bình thường — không crash, không ra kết quả sai, không ném exception bất ngờ.

Ví dụ sai

public class Bird {
    public void fly() {
        System.out.println("Đang bay...");
    }
}

public class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Chim cánh cụt không biết bay!");
    }
}

// Ở đâu đó trong code
public void makeBirdFly(Bird bird) {
    bird.fly(); // 💥 Crash nếu bird là Penguin
}

Penguin kế thừa Bird nhưng lại phá vỡ contract của method fly(). Code gọi makeBirdFly() hoàn toàn không biết rằng nó có thể nhận một Penguin và bị crash. Đây là vi phạm LSP — subclass không thể thay thế parent class một cách an toàn.

Ví dụ đúng

public interface Bird {
    void eat();
}

public interface FlyingBird extends Bird {
    void fly();
}

public class Sparrow implements FlyingBird {
    public void eat() { System.out.println("Chim sẻ đang ăn."); }
    public void fly() { System.out.println("Chim sẻ đang bay."); }
}

public class Penguin implements Bird {
    public void eat() { System.out.println("Chim cánh cụt đang ăn."); }
}

// An toàn — chỉ những Bird biết bay mới được truyền vào đây
public void makeBirdFly(FlyingBird bird) {
    bird.fly();
}

Bằng cách tách FlyingBird ra khỏi Bird, Penguin không bao giờ bị ép implement fly(). Hàm makeBirdFly() giờ chỉ nhận FlyingBird — đảm bảo mọi object truyền vào đều có thể bay. LSP được đảm bảo.

I — Interface Segregation Principle

Nguyên tắc

Clients should not be forced to depend upon interfaces that they do not use.

— Robert C. Martin

Đừng tạo ra những interface “béo” chứa quá nhiều method. Nếu một class chỉ cần dùng một phần interface đó, nó không nên bị ép implement những method còn lại.

Ví dụ sai

public interface WorkerActions {
    void work();
    void eat();
    void sleep();
}

public class RobotWorker implements WorkerActions {
    public void work() { System.out.println("Robot đang làm việc."); }
    public void eat() { throw new UnsupportedOperationException("Robot không ăn!"); }
    public void sleep() { throw new UnsupportedOperationException("Robot không ngủ!"); }
}

RobotWorker bị ép implement eat()sleep() dù robot không có những hành vi này. Kết quả là phải throw exception — một dấu hiệu rõ ràng của interface thiết kế sai. Nếu sau này WorkerActions thêm method takeBreak(), tất cả các class implement nó — kể cả robot — đều phải sửa theo.

Ví dụ đúng

public interface Workable {
    void work();
}

public interface Feedable {
    void eat();
}

public interface Restable {
    void sleep();
}

public class HumanWorker implements Workable, Feedable, Restable {
    public void work() { System.out.println("Người đang làm việc."); }
    public void eat() { System.out.println("Người đang ăn."); }
    public void sleep() { System.out.println("Người đang ngủ."); }
}

public class RobotWorker implements Workable {
    public void work() { System.out.println("Robot đang làm việc."); }
}

Mỗi interface giờ chỉ mô tả một khả năng cụ thể. HumanWorker cần cả ba thì implement cả ba. RobotWorker chỉ cần làm việc thì implement mỗi Workable — không thừa, không thiếu. Thêm interface mới trong tương lai cũng không ảnh hưởng đến những class không liên quan.

D — Dependency Inversion Principle

Nguyên tắc

High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

— Robert C. Martin

Đây là nguyên tắc hay bị hiểu nhầm nhất. Nó không có nghĩa là “đảo ngược dependency” theo nghĩa đen, mà là: cả module cấp cao lẫn cấp thấp đều nên phụ thuộc vào interface, không phải phụ thuộc trực tiếp vào nhau.

Ví dụ sai

public class NotificationService {
    private EmailSender emailSender = new EmailSender(); // Phụ thuộc trực tiếp

    public void notify(String message) {
        emailSender.send(message);
    }
}

NotificationService (module cấp cao) đang tự tạo và phụ thuộc trực tiếp vào EmailSender (module cấp thấp). Điều này gây ra hai vấn đề:

  • Muốn chuyển sang gửi SMS hay push notification? Phải vào sửa NotificationService
  • Muốn viết unit test cho NotificationService mà không gửi email thật? Rất khó, vì EmailSender được tạo thẳng bên trong

Ví dụ đúng

public interface MessageSender {
    void send(String message);
}

public class EmailSender implements MessageSender {
    public void send(String message) {
        System.out.println("Gửi email: " + message);
    }
}

public class SmsSender implements MessageSender {
    public void send(String message) {
        System.out.println("Gửi SMS: " + message);
    }
}

public class NotificationService {
    private final MessageSender sender;

    public NotificationService(MessageSender sender) {
        this.sender = sender;
    }

    public void notify(String message) {
        sender.send(message);
    }
}

Giờ NotificationService chỉ biết đến interface MessageSender, không biết gì về EmailSender hay SmsSender. Muốn đổi kênh gửi? Chỉ cần inject implementation khác vào. Muốn test? Tạo một MockSender implement MessageSender là xong — không cần gửi email thật.

Tóm gọn lại

Nguyên tắc Ý chính Dấu hiệu vi phạm
SRP Mỗi class một lý do để thay đổi Class làm quá nhiều việc không liên quan
OCP Mở rộng không cần sửa code cũ Chuỗi if/else dài theo loại
LSP Subclass thay thế được parent an toàn Subclass throw exception với method thừa kế
ISP Interface gọn, không ép implement thừa Class implement method rồi throw exception
DIP Phụ thuộc vào abstraction, không phụ thuộc trực tiếp new ConcreteClass() trong constructor

SOLID không phải là quy tắc cứng nhắc bắt buộc áp dụng mọi nơi. Nhưng khi dự án lớn dần và bạn bắt đầu sợ sửa code — đó là lúc quay lại những nguyên tắc này và hỏi: tôi đã vi phạm điều gì?