Hầu hết hệ thống Java trong doanh nghiệp đều bắt đầu gọn gàng rồi dần rối: một class OrderService vừa tính tiền, vừa ghi database, vừa gửi email; thêm một loại khuyến mãi thì phải mở đúng hàm đã chạy ổn định để sửa; viết unit test thì phải dựng cả SMTP và database. Năm nguyên lý SOLID là bộ chuẩn đoán và bộ cách chữa phổ biến nhất cho những triệu chứng đó. Bài này đi qua từng nguyên lý SOLID bằng một hệ thống đặt hàng nhỏ, chạy thật, biên dịch với --release 17 (API và ngôn ngữ của Java 17 LTS), dán output thật, và nói rõ khi nào áp dụng SOLID là thừa.

SOLID là gì và từ đâu ra

Robert C. Martin (thường gọi là Uncle Bob) tổng hợp các nguyên lý thiết kế hướng đối tượng đầu tiên trong bài báo Design Principles and Design Patterns năm 2000. Từ viết tắt SOLID được Michael Feathers đặt khoảng năm 2004 để dễ nhớ. Hai nguyên lý có gốc xa hơn: Open–Closed do Bertrand Meyer nêu trong sách Object-Oriented Software Construction (1988), còn Liskov Substitution xuất phát từ bài giảng của Barbara Liskov tại OOPSLA 1987 và được Liskov cùng Jeannette Wing chính thức hoá năm 1994. Bạn có thể đối chiếu lịch sử này ở bài gốc của Martin (bản lưu của Principles and Patterns), bài Liskov–Wing A behavioral notion of subtyping (ACM TOPLAS, 1994), và các trang tổng quan trang tổng quan về SOLID và trang về nguyên lý thay thế Liskov.

Chữ cáiNguyên lýPhát biểu ngắnDấu hiệu vi phạm
SSingle ResponsibilityMột class chỉ nên có một lý do để thay đổiMột file bị sửa bởi nhiều nhóm với nhiều yêu cầu khác nhau
OOpen–ClosedMở để mở rộng, đóng với sửa đổiThêm tính năng mới phải sửa switch hoặc chuỗi if cũ
LLiskov SubstitutionLớp con dùng được ở mọi nơi dùng lớp cha mà không đổi hành vi đúng đắnLớp con ném UnsupportedOperationException, hoặc code kiểm tra instanceof
IInterface SegregationClient không bị buộc phụ thuộc vào method nó không dùngInterface "béo", nhiều lớp cài đặt method rỗng
DDependency InversionModule cấp cao phụ thuộc abstraction, không phụ thuộc cài đặt cụ thểDùng new cho dịch vụ bên ngoài ngay trong logic nghiệp vụ

Một lưu ý quan trọng ngay từ đầu: SOLID là bộ nguyên lý định hướng, không phải định lý. SOLID giúp bạn đặt câu hỏi đúng khi review code, chứ không phải luật để áp máy móc vào mọi class.

S trong SOLID: Single Responsibility, mỗi class một lý do để thay đổi

Hiểu nhầm phổ biến nhất về chữ S trong SOLID là "một class chỉ làm một việc". Phát biểu gốc chính xác hơn: một class chỉ nên có một lý do để thay đổi. Lý do thay đổi gắn với một nhóm người yêu cầu: kế toán đổi cách làm tròn tiền, đội hạ tầng đổi nhà cung cấp email, đội dữ liệu đổi schema bảng đơn hàng. Nếu ba nhóm cùng sửa một file, ba nhóm sẽ giẫm chân nhau.

Ví dụ dưới đây là phiên bản "trước" quen thuộc: một method checkout tính tiền, lưu và gửi thư.

static class OrderServiceBefore {
    void checkout(String customerEmail, List<Line> lines) {
        BigDecimal total = BigDecimal.ZERO;
        for (Line l : lines) total = total.add(l.unitPrice().multiply(BigDecimal.valueOf(l.qty())));
        System.out.println("[SQL] INSERT INTO orders ... total=" + total);
        System.out.println("[SMTP] Gui mail toi " + customerEmail + ": tong " + total);
    }
}

Sau khi tách, mỗi trách nhiệm là một kiểu riêng, và OrderService chỉ còn nhiệm vụ điều phối:

static class PriceCalculator {
    BigDecimal total(List<Line> lines) {
        return lines.stream()
                .map(l -> l.unitPrice().multiply(BigDecimal.valueOf(l.qty())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}
interface OrderRepository { void save(BigDecimal total); }
interface Notifier { void orderPlaced(String to, BigDecimal total); }

static class OrderService {
    private final PriceCalculator calc;
    private final OrderRepository repo;
    private final Notifier notifier;
    OrderService(PriceCalculator calc, OrderRepository repo, Notifier notifier) {
        this.calc = calc; this.repo = repo; this.notifier = notifier;
    }
    void checkout(String email, List<Line> lines) {
        BigDecimal total = calc.total(lines);
        repo.save(total);
        notifier.orderPlaced(email, total);
    }
}

Chạy cả hai phiên bản trên cùng một giỏ hàng (hai áo 199.000 đồng và một nón 89.000 đồng) cho cùng kết quả. Hành vi không đổi, chỉ cấu trúc thay đổi:

$ java -cp out Srp
--- Truoc khi tach ---
[SQL] INSERT INTO orders ... total=487000
[SMTP] Gui mail toi khach@example.com: tong 487000
--- Sau khi tach ---
[SQL] INSERT INTO orders ... total=487000
[SMTP] Gui mail toi khach@example.com: tong 487000

Lợi ích thấy ngay của S trong SOLID: test PriceCalculator không cần mock gì, và việc đổi nhà cung cấp email chỉ chạm vào đúng một class cài đặt Notifier. Đánh đổi: số lượng kiểu tăng, và nếu tách quá sớm bạn có nhiều class mỏng không ai cần. Quy tắc thực dụng: chỉ tách khi bạn thực sự thấy hai lý do thay đổi khác nhau xuất hiện trong lịch sử commit.

O trong SOLID: Open–Closed, thêm hành vi bằng code mới

Nguyên lý này nói rằng khi yêu cầu mới tới, bạn nên thêm code mới thay vì sửa code đang chạy ổn. Dấu hiệu cổ điển là một switch theo loại khách hàng:

static BigDecimal discountBefore(String tier, BigDecimal subtotal) {
    switch (tier) {
        case "VIP":   return subtotal.multiply(new BigDecimal("0.10"));
        case "STAFF": return subtotal.multiply(new BigDecimal("0.20"));
        default:      return BigDecimal.ZERO;
    }
}

Mỗi chính sách khuyến mãi mới buộc bạn mở hàm này ra, nguy cơ làm hỏng các nhánh đang đúng. Cách làm theo SOLID là đưa chính sách thành một interface; sau đó thêm chính sách mới chỉ là thêm một kiểu mới:

interface DiscountPolicy { BigDecimal discount(BigDecimal subtotal); }
record Percent(String pct) implements DiscountPolicy {
    public BigDecimal discount(BigDecimal s) { return s.multiply(new BigDecimal(pct)).setScale(0, RoundingMode.HALF_UP); }
}
record NoDiscount() implements DiscountPolicy {
    public BigDecimal discount(BigDecimal s) { return BigDecimal.ZERO; }
}
// Chính sách mới thêm sau, không đụng tới code cũ: giảm 15%, tối đa 50.000đ
record FlatCapped(BigDecimal pct, BigDecimal cap) implements DiscountPolicy {
    public BigDecimal discount(BigDecimal s) { return s.multiply(pct).min(cap).setScale(0, RoundingMode.HALF_UP); }
}
static BigDecimal payable(BigDecimal subtotal, DiscountPolicy p) { return subtotal.subtract(p.discount(subtotal)); }

Kết quả với đơn hàng 487.000 đồng:

$ java -cp out Ocp
switch VIP -> giam 48700.00
NoDiscount[] -> phai tra 487000
Percent[pct=0.10] -> phai tra 438300
FlatCapped[pct=0.15, cap=50000] -> phai tra 437000

Chính sách FlatCapped giảm 15% (73.050 đồng) nhưng bị chặn ở 50.000 đồng nên khách trả 437.000 đồng. Hàm payable không hề biết chính sách này tồn tại. Đây là OCP của SOLID đang hoạt động, và cũng là bản chất của mẫu Strategy mà các bài sau trong series sẽ quay lại. Lưu ý cách làm này chỉ có lợi nếu trục thay đổi là có thật: nếu cửa hàng chỉ có đúng hai mức giảm giá và không ai nghĩ sẽ thêm, một switch ba dòng vẫn dễ đọc hơn ba kiểu và một interface.

L trong SOLID: Liskov Substitution, kế thừa là hợp đồng hành vi

Chữ L trong SOLID lấy từ phát biểu hình thức của Liskov và Wing: nếu một thuộc tính đúng cho mọi đối tượng kiểu T thì nó cũng phải đúng cho mọi đối tượng của kiểu con S. Nói theo ngôn ngữ hợp đồng: lớp con không được đòi hỏi tiền điều kiện chặt hơn, không được hứa hậu điều kiện yếu hơn, và phải giữ các bất biến của lớp cha. Vì thế "Square là một Rectangle" đúng trong toán học nhưng sai trong thiết kế mã nguồn có setter độc lập.

static class Rectangle {
    protected int w, h;
    void setWidth(int w) { this.w = w; }
    void setHeight(int h) { this.h = h; }
    int area() { return w * h; }
}
static class Square extends Rectangle {
    @Override void setWidth(int w) { this.w = w; this.h = w; }
    @Override void setHeight(int h) { this.w = h; this.h = h; }
}
static void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    System.out.println(r.getClass().getSimpleName() + ": ky vong 20, thuc te " + r.area());
}

Hàm resize viết đúng theo hợp đồng của Rectangle (chiều rộng và chiều cao độc lập). Truyền Square vào, kết quả sai:

$ java -cp out Lsp
Rectangle: ky vong 20, thuc te 20
Square: ky vong 20, thuc te 16
Shape: 20, 25
PaymentMethod tra 50000 -> 50000
VoucherPayment tra 50000 -> LOI: Voucher chi ap dung tu 100.000d

Cách sửa là bỏ quan hệ kế thừa, dùng một interface Shape chỉ có area() và hai record độc lập (Rect(5, 4) cho 20, Sq(5) cho 25). Ví dụ thứ hai gần nghiệp vụ hơn: VoucherPayment kế thừa PaymentMethod nhưng ném IllegalArgumentException khi số tiền dưới 100.000 đồng. Đó là tăng tiền điều kiện: mọi nơi nhận PaymentMethod đang coi mọi số tiền dương là hợp lệ, nên đoạn code thanh toán sẽ nổ đúng lúc khách chọn voucher. Hướng xử lý là đưa điều kiện vào kiểu, ví dụ một interface EligibleFor kiểm tra trước, thay vì ném lỗi bất ngờ.

Trong chính JDK cũng có vùng xám: Collection.add được khai báo là "optional operation" trong Javadoc Java 17, nên một danh sách bất biến ném UnsupportedOperationException vẫn tuân thủ hợp đồng đã công bố. Điểm mấu chốt là hợp đồng phải được viết ra; vi phạm Liskov xảy ra khi hành vi của lớp con lệch với hợp đồng mà người gọi có quyền kỳ vọng.

I trong SOLID: Interface Segregation, đừng ép client phụ thuộc thứ nó không dùng

Giả sử bạn định nghĩa một cổng thanh toán "đa năng" với ba method charge, refund, createSubscription. Thanh toán khi nhận hàng (COD) không có hoàn tiền tự động và không có thanh toán định kỳ, nên lớp COD buộc phải cài hai method bằng cách ném ngoại lệ, một dấu hiệu vi phạm cả ISP lẫn Liskov.

interface Chargeable { String charge(long amount); }
interface Refundable { String refund(String txId); }
interface Recurring { String createSubscription(String plan); }

static class CodGateway implements Chargeable {
    public String charge(long amount) { return "COD-" + amount; }
}
static class CardGateway implements Chargeable, Refundable, Recurring {
    public String charge(long amount) { return "CARD-" + amount; }
    public String refund(String txId) { return "REFUND-" + txId; }
    public String createSubscription(String plan) { return "SUB-" + plan; }
}
static void checkout(Chargeable g, long amount) { System.out.println("checkout: " + g.charge(amount)); }
static void giveRefund(Refundable g, String tx) { System.out.println("refund: " + g.refund(tx)); }
$ java -cp out Isp
interface beo -> COD khong hoan tien tu dong
checkout: COD-250000
checkout: CARD-250000
refund: REFUND-TX1

Lợi ích lớn nhất của ISP trong SOLID là lỗi chuyển từ lúc chạy sang lúc biên dịch. Dòng giveRefund(new CodGateway(), "TX1") không biên dịch được vì CodGateway không phải Refundable, trong khi với interface béo bạn chỉ phát hiện khi khách thật bấm hoàn tiền. Điều này cũng làm test dễ hơn: một fake cho giveRefund chỉ cần cài một method.

D trong SOLID: Dependency Inversion, logic cấp cao không biết hạ tầng

Chữ D trong SOLID, Dependency Inversion, có hai nửa: module cấp cao và cấp thấp đều phụ thuộc abstraction; và abstraction không phụ thuộc chi tiết. Trong ví dụ, CheckoutService (nghiệp vụ) chỉ biết interface PaymentGateway. Cài đặt cụ thể (VNPay, Stripe, hay một fake trong test) là chi tiết được cắm vào từ ngoài.

interface PaymentGateway { boolean charge(String orderId, long amount); }

static class CheckoutService {
    private final PaymentGateway gateway;
    CheckoutService(PaymentGateway gateway) { this.gateway = gateway; }
    String checkout(String orderId, long amount) {
        return gateway.charge(orderId, amount) ? "PAID" : "FAILED";
    }
}
static class FakeGateway implements PaymentGateway {
    final List<String> calls = new ArrayList<>();
    boolean approve = true;
    public boolean charge(String orderId, long amount) { calls.add(orderId + ":" + amount); return approve; }
}
$ java -cp out Dip
PAID calls=[DH-1001:320000]
FAILED calls=[DH-1001:320000, DH-1002:90000]

Hai lần chạy, một lần cổng chấp nhận và một lần từ chối, đều không cần mạng hay tài khoản thật. Phần "ghép" các đối tượng (new CheckoutService(fake)) nằm ở điểm khởi động của ứng dụng, thường gọi là composition root. Spring làm đúng việc đó: IoC container của Spring Framework 5.3 tạo các bean và tiêm phụ thuộc qua constructor. Cần phân biệt: Dependency Inversion là nguyên lý thiết kế, còn Dependency Injection là kỹ thuật cài đặt; có thể tiêm phụ thuộc mà vẫn phụ thuộc thẳng vào class cụ thể, khi đó chưa đảo ngược gì cả.

Các nguyên lý SOLID hỗ trợ nhau thế nào

Năm nguyên lý không đứng riêng. Tách trách nhiệm (S) tạo ra các mảnh nhỏ; interface hẹp (I) cho mỗi mảnh một hợp đồng gọn; các mảnh cắm được vào nhau qua abstraction (D); thêm biến thể bằng kiểu mới (O) chỉ an toàn khi các kiểu mới tuân thủ hợp đồng (L). Nhìn ngược lại cũng đúng: một class vi phạm Liskov thường là hệ quả của interface quá béo, và một switch vi phạm OCP thường là dấu hiệu thiếu một abstraction.

Các thư viện lớn của Java áp dụng SOLID một cách tự nhiên. java.util.Comparator là interface một method cho phép mở rộng cách sắp xếp mà không sửa Collections.sort (OCP, ISP). JDBC khai báo Connection, Statement là interface để code nghiệp vụ độc lập với driver (DIP). Spring Framework xây dựng toàn bộ mô hình bean quanh việc tiêm abstraction.

Áp dụng SOLID dần dần vào dự án có sẵn

Với codebase đang chạy, không nên "refactor theo SOLID" một lần. Cách an toàn hơn là gắn từng nguyên lý SOLID với một sự kiện cụ thể. Khi sửa lỗi trong một class phải đọc hơn 500 dòng, hãy tách trách nhiệm (S). Khi phải thêm nhánh case lần thứ ba, hãy đưa nhánh đó vào interface (O). Khi viết test mà phải dựng cơ sở dữ liệu thật, hãy tiêm abstraction (D). Cách làm này bám theo nhu cầu thật, và mỗi bước refactor có test bảo vệ. SOLID vì thế trở thành công cụ giảm chi phí thay đổi, không phải mục tiêu tự thân. Khi trình bày với đội mới, hãy dùng số liệu cụ thể (số file bị sửa cho mỗi yêu cầu, thời gian chạy test) để cho thấy SOLID đã cải thiện điều gì.

Khi nào không nên áp dụng SOLID máy móc

SOLID giúp ích nhiều nhất ở code sống lâu và nhiều người cùng sửa, vì vậy hãy cân nhắc chi phí SOLID trước khi áp dụng:

  • Script và prototype ngắn hạn: SOLID chưa đáng có, một tác vụ chạy một lần không cần interface cho mọi thứ.
  • Trục thay đổi chưa tồn tại: tạo abstraction cho thứ chưa từng thay đổi là over-engineering. Chờ đến khi có yêu cầu thứ hai rồi mới tách (quy tắc "ba lần mới trừu tượng hoá").
  • Hiệu năng cực nhạy: abstraction có chi phí gọi gián tiếp nhỏ; hãy đo bằng JMH trước khi kết luận, đừng đoán.
  • Đội nhỏ, codebase nhỏ: sáu class mỏng cho một chức năng 30 dòng làm người mới khó đọc hơn.

Lỗi thường gặp

  1. Hiểu SRP trong SOLID là "mỗi method một class": dẫn tới hàng chục class mỏng, logic rải rác khó theo dõi. Hãy đo bằng lý do thay đổi, không đo bằng số dòng.
  2. Interface một-một với class (FooService và FooServiceImpl) chỉ để "có interface": không mang lại đảo ngược phụ thuộc thật nếu không có cài đặt thứ hai hay nhu cầu test.
  3. Kế thừa để tái sử dụng code thay vì để thay thế kiểu: dễ vi phạm Liskov. Ưu tiên composition.
  4. Nhầm DI container với DIP: interface đặt trong cùng package với cài đặt hạ tầng và bị module nghiệp vụ import ngược vẫn chưa đảo ngược gì; interface nên thuộc về phía module cấp cao.
  5. Dùng double để tính tiền: trong các ví dụ ở trên dùng BigDecimal để tránh sai số làm tròn; đừng đánh đổi độ đúng của nghiệp vụ lấy ví dụ gọn.

Checklist khi review code theo SOLID

Dùng năm câu hỏi sau làm checklist SOLID ngắn trong buổi review pull request:

  • Class này bị sửa vì bao nhiêu loại yêu cầu khác nhau trong 6 tháng qua?
  • Thêm một biến thể mới (loại khuyến mãi, cổng thanh toán) cần sửa ở bao nhiêu chỗ?
  • Có chỗ nào kiểm tra instanceof hay bắt UnsupportedOperationException để "đỡ" cho lớp con không?
  • Client có đang phụ thuộc method mà nó không bao giờ gọi?
  • Test nghiệp vụ có chạy được không cần mạng, database, SMTP?

Toàn bộ mã nguồn trong bài (Srp, Ocp, Lsp, Isp, Dip) được biên dịch bằng javac --release 17 -d out *.java và chạy bằng java -cp out <Tên> với mức API Java 17, máy 16 GB RAM chạy macOS. Các chuỗi in ra cố ý viết không dấu để tránh lỗi mã hoá ở terminal, nên output bên trên không có dấu tiếng Việt. Sau khi nắm SOLID, bài tiếp theo của series chuyển từ nguyên lý sang mẫu thiết kế, bắt đầu với mẫu tạo đối tượng gây tranh cãi nhất.

Bài tiếp theo

Đọc tiếp: Singleton pattern trong Java: lazy, holder, enum và Java Memory Model.