Coi SOLID như dãy đèn cảnh báo trên bảng đồng hồ xe thì dùng nó đúng hơn nhiều. Đèn không phải luật bắt bạn lái kiểu gì — nó sáng lên khi có thứ sắp hỏng, để bạn mở nắp ca-pô đúng chỗ. Năm nguyên tắc SOLID cũng vậy: chúng không phải năm việc phải làm trước, mà là năm ngọn đèn báo khi một thiết kế bắt đầu mục — lớp khó sửa, khó test, sửa một chỗ hỏng ba chỗ. Bài này bật từng ngọn đèn lên bằng một đoạn mã đang hỏng, rồi chỉ chỗ cần mở nắp ca-pô.

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.

Muốn tự soi mã mình thì làm thử trong ba mươi giây: tìm một lớp con có ghi đè phương thức của lớp cha, đọc phần ghi đè, và hỏi đúng một câu — nó có làm được ít hơn thứ lớp cha đã hứa không? Ném ngoại lệ ở chỗ cha không ném, thu hẹp khoảng giá trị chấp nhận được, hay lặng lẽ bỏ qua một tham số — cả ba đều là vi phạm chữ L, và đều cho ra đúng kiểu con số 16: mã vẫn biên dịch, test của lớp con vẫn xanh, cái hỏng nằm ở một hàm không hề biết gì về lớp con của bạn.

Mẫu số chung

SOLID sinh ra trong thế giới Java/C# đầu những năm 2000, nhưng điều đáng nói là phần lõi của nó không phụ thuộc ngôn ngữ — và cách các ngôn ngữ khác tiếp nhận nó cho thấy nguyên tắc nào là vĩnh cửu, nguyên tắc nào chỉ là cách chữa cho một kiểu ngôn ngữ cụ thể.

  • Nguyên tắc Liskov (chữ L) là thứ phổ quát nhất, vì nó không nói về cú pháp mà về hợp đồng: "lớp con không được hứa ít hơn lớp cha". Bài toán hình vuông–chữ nhật được dạy y nguyên trong C#, Python, C++ — bất kỳ đâu có kế thừa. Rust thì cắt gốc vấn đề: không có kế thừa triển khai, chỉ có trait, nên cả họ lỗi L lẫn I gần như biến mất.
  • Nghịch đảo phụ thuộc (chữ D) là nơi Go toả sáng: interface của Go được thoả mãn ngầm theo cấu trúc, nên "interface thuộc về bên gọi" không phải lời khuyên mà là mặc định — bên dùng khai interface nhỏ nó cần, bất kỳ kiểu nào có đủ phương thức tự động khớp. Cái mà Java phải kỷ luật mới làm đúng thì Go làm đúng tự nhiên.
  • Tách giao diện (chữ I) chính là triết lý "interface nhỏ" của Go và của cả Unix ("làm một việc cho tốt"). Trách nhiệm đơn lẻ (chữ S) và đóng-mở (chữ O) thì đúng với mọi mô hình, kể cả hàm thuần tuý — một hàm cũng nên có một lý do để sửa.

Sợi chỉ chung đáng mang theo: SOLID không phải "luật của OOP" mà là năm cách diễn đạt của một ý lớn hơn — giữ cho các phần ít phụ thuộc vào nhau, để đổi một phần không làm gãy phần khác. Ngôn ngữ hàm gọi nó là "hàm thuần tuý và dữ liệu bất biến", Go gọi là "interface nhỏ, thoả mãn ngầm", Rust gọi là "trait và quyền sở hữu" — khác từ ngữ, cùng mục tiêu. Và trong số năm chữ, chữ L là chữ chạy sai nếu phạm, nên nếu chỉ nhớ một điều từ cả bài, hãy nhớ: trước khi cho B kế thừa A, hỏi "B có thay A được ở mọi chỗ đang dùng A không" — không thì dùng thành phần, đừng dùng kế thừa.

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.