SOLID là chỗ dễ biến thành lý thuyết suông nhất trong mọi tài liệu lập trình. Năm chữ cái, năm định nghĩa trang trọng, và người đọc gật gù rồi về viết mã y như cũ.

Nên bài này làm ngược lại: mỗi nguyên tắc bắt đầu bằng một đoạn mã đang hỏng, rồi sửa. Và nguyên tắc thứ ba — chữ L — tôi sẽ chứng minh bằng một con số sai chạy được.

Đây cũng là bài khép lại chặng hướng đối tượng. Mười bốn bài vừa qua đã cho đủ công cụ; SOLID là cách quyết định dùng công cụ nào lúc nào.

S — mỗi lớp một lý do để thay đổi

Nguyên tắc trách nhiệm đơn lẻ hay bị hiểu thành "mỗi lớp chỉ làm một việc", rồi dẫn tới những lớp một phương thức vô nghĩa. Cách hiểu chính xác hơn: mỗi lớp chỉ nên có một lý do để bị sửa.

// Ba lý do để sửa nằm chung một chỗ
class DonHang {
    void tinhTongTien() { ... }        // đổi khi quy tắc giá thay đổi
    void luuVaoCsdl() { ... }          // đổi khi schema thay đổi
    void guiEmailXacNhan() { ... }     // đổi khi mẫu email thay đổi
}

Ba nhóm người khác nhau — nghiệp vụ, hạ tầng, marketing — đều có thể yêu cầu sửa lớp này. Mỗi lần sửa là một lần có nguy cơ làm hỏng hai phần còn lại.

class DonHang        { long tinhTongTien() { ... } }   // chỉ nghiệp vụ
class KhoDonHang     { void luu(DonHang d) { ... } }   // chỉ lưu trữ
class ThongBaoDonHang{ void guiXacNhan(DonHang d) { ... } }

Dấu hiệu nhận biết trong mã thật: lớp có tên chứa chữ "And", lớp phải import cả javax.mail lẫn java.sql, hoặc lớp mà mỗi lần sửa lại phải chạy hết bộ test.

O — mở để mở rộng, đóng để sửa đổi

// Thêm loại giảm giá mới = sửa hàm này, lần nào cũng vậy
long apDung(long goc, String loai, long thamSo) {
    if (loai.equals("phan_tram")) return goc - goc * thamSo / 100;
    if (loai.equals("so_tien"))   return Math.max(0, goc - thamSo);
    throw new IllegalArgumentException("loại lạ: " + loai);
}

Với sealed và pattern matching của bài hôm trước:

sealed interface Giam permits TheoPhanTram, TheoSoTien {}
record TheoPhanTram(int phanTram) implements Giam {}
record TheoSoTien(long soTien) implements Giam {}

long apDung(long goc, Giam g) {
    return switch (g) {
        case TheoPhanTram(int p) -> goc - goc * p / 100;
        case TheoSoTien(long s)  -> Math.max(0, goc - s);
    };
}
  100.000 giảm 10%     = 90000
  100.000 giảm 30.000  = 70000

Bản sau hơn ở ba điểm: mỗi loại giảm mang đúng dữ liệu của nó (int với long, không phải một tham số chung chung), không có chuỗi ma thuật để gõ sai, và thêm loại mới thì trình biên dịch chỉ ngay chỗ phải sửa.

Chú ý là bản "đúng" vẫn phải sửa apDung khi thêm loại — nhưng trình biên dịch bắt bạn sửa, thay vì để lọt tới lúc chạy. Với những trường hợp thêm loại rất thường xuyên, hướng thuần đa hình (mỗi loại tự cài apDung của mình) còn đóng hơn nữa. Hai hướng, chọn theo thứ hay thay đổi hơn — đúng bài toán biểu thức đã nhắc ở bài sealed.

L — lớp con phải dùng thay được lớp cha

Đây là nguyên tắc trừu tượng nhất, nên tôi cho chạy.

class ChuNhat {
    protected int rong, cao;
    void setRong(int r) { rong = r; }
    void setCao(int c)  { cao = c; }
    int dienTich() { return rong * cao; }
}

class Vuong extends ChuNhat {          // "hình vuông LÀ hình chữ nhật"
    @Override void setRong(int r) { rong = cao = r; }
    @Override void setCao(int c)  { rong = cao = c; }
}

Và một hàm hoàn toàn bình thường, chỉ biết về ChuNhat:

static void kiemTra(ChuNhat h) {
    h.setRong(5);
    h.setCao(4);
    System.out.println("đặt 5x4, mong đợi 20, thực tế: " + h.dienTich());
}
  với ChuNhat:
    đặt 5x4, mong đợi 20, thực tế: 20
  với Vuong (lớp con!):
    đặt 5x4, mong đợi 20, thực tế: 16

Hàm kiemTra không hề biết Vuong tồn tại. Nó chỉ làm đúng những gì hợp đồng của ChuNhat cho phép — đặt chiều rộng, đặt chiều cao, tính diện tích. Vậy mà kết quả sai.

Đây là vi phạm Liskov: Vuong là lớp con hợp lệ về cú pháp nhưng phá vỡ một giả định ngầm của lớp cha — rằng hai chiều độc lập với nhau.

Điều đáng nhớ: "là một" trong tiếng Việt không đủ để kết luận "kế thừa được". Hình vuông đúng là một hình chữ nhật trong hình học. Nhưng Vuong không thay thế được ChuNhat trong mã có setter.

Cách sửa:

Bỏ setter đi, làm cả hai bất biến. Khi đó không còn giả định nào để phá:

record ChuNhat(int rong, int cao) { int dienTich() { return rong * cao; } }
record Vuong(int canh)            { int dienTich() { return canh * canh; } }

Hoặc dùng sealed interface Hinh với hai record cài đặt — đúng mô hình của bài sealed.

Cách nhận biết vi phạm Liskov trong mã của bạn: lớp con ghi đè một phương thức rồi ném UnsupportedOperationException, hoặc lớp con siết chặt điều kiện đầu vào so với lớp cha. Cả hai đều nghĩa là lớp con không thay thế được lớp cha.

I — nhiều giao diện nhỏ hơn một giao diện to

interface MayIn {
    void in(String s);
    void quet(String s);      // máy in rẻ tiền không quét được
    void fax(String s);       // và chẳng ai còn fax
}

Một máy in đơn giản cài giao diện này buộc phải viết hai phương thức nó không làm được, thường là ném ngoại lệ — mà đó lại là vi phạm Liskov như vừa nói.

interface CoTheIn  { void in(String s); }
interface CoTheQuet { void quet(String s); }

class MayInDon implements CoTheIn { ... }
class MayDaNang implements CoTheIn, CoTheQuet { ... }

Interface nhỏ ghép lại được — đây chính là điểm mạnh "một lớp cài nhiều interface" ở bài trước. Closeable, Comparable, Iterable trong JDK đều chỉ có một hai phương thức, và chính vì thế mới ghép được thoải mái.

D — phụ thuộc vào trừu tượng, không vào chi tiết

class DichVu {
    private final KhoPostgres kho = new KhoPostgres();   // đóng đinh vào PostgreSQL
    void xuLy(String s) { kho.luu(s.toUpperCase()); }
}

Lớp này không test được nếu không có PostgreSQL chạy sẵn, và không đổi được sang nơi lưu khác.

interface KhoLuu { void luu(String s); }

class DichVu {
    private final KhoLuu kho;
    DichVu(KhoLuu kho) { this.kho = kho; }      // nhận từ bên ngoài
    void xuLy(String s) { kho.luu(s.toUpperCase()); }
}
  kho giả nhận được: [XIN CHÀO]

Test chạy trong một phần nghìn giây với một List trong bộ nhớ. Không Docker, không CSDL, không cấu hình.

Hai chi tiết dễ bỏ qua: interface nên thuộc về bên gọi, tức đặt cùng gói với DichVu chứ không cùng gói với KhoPostgres — vì DichVu là bên định ra cần gì. Và tiêm qua hàm khởi tạo chứ không qua setter hay qua trường, để đối tượng không bao giờ tồn tại ở trạng thái thiếu phụ thuộc.

Đây cũng là lý do Spring gọi là "dependency injection" — nhưng nguyên tắc này không cần framework nào cả, chỉ cần một tham số trong hàm khởi tạo.

Đừng biến SOLID thành giáo điều

Sau vài năm áp dụng, tôi rút ra ba điều:

SOLID là công cụ chẩn đoán, không phải danh sách phải làm. Khi mã khó sửa, khó test, hoặc sửa một chỗ hỏng ba chỗ — lấy năm nguyên tắc ra soi để tìm nguyên nhân. Đừng áp dụng trước khi có vấn đề.

Áp dụng quá tay còn tệ hơn không áp dụng. Một dự án với ba mươi interface mỗi cái một bản cài đặt, sáu tầng trừu tượng để lưu một bản ghi — đó không phải SOLID, đó là sợ hãi. Hãy nhớ lời khuyên ở bài interface: đừng tạo trừu tượng cho một tương lai chưa chắc tới.

Chữ L quan trọng hơn bốn chữ còn lại. Bốn nguyên tắc kia làm mã dễ bảo trì hơn. Vi phạm chữ L làm mã chạy sai — và sai ở một hàm không hề biết gì về lớp con của bạn, đúng như con số 16 ở trên.

Hết chặng hướng đối tượng

Mười bốn bài vừa qua: lớp và đối tượng, hàm khởi tạo, đóng gói, kế thừa, đa hình, interface, equals, sắp xếp, lớp lồng, record, sealed, pattern matching, và SOLID.

Nếu chỉ giữ lại ba ý, tôi mong đó là: đối tượng phải tự bảo vệ ràng buộc của mình; ưu tiên thành phần hơn kế thừa; và dùng kiểu để trình biên dịch bắt lỗi hộrecord, sealed, pattern matching đều phục vụ đúng ý này.

Từ ngày mai ta sang chặng ngoại lệ và tài nguyên: sáu bài về checked với unchecked, try-with-resources, ghi log, Optional, và cách thiết kế thông báo lỗi cho người phải đọc log lúc hai giờ sáng.