Command Palette

Search for a command to run...

[Advanced Java] Nguyên lý SOLID trong Java: Năm quy tắc và khi nào nên bỏ qua

Bài đầu tiên của khóa này khép lại bằng một ví dụ khó chịu: một subclass thỏa mãn compiler ở mọi khía cạnh mà vẫn trả cho caller một kết quả sai. Inheritance đã được dùng đúng theo mọi luật mà ngôn ngữ kiểm tra được, vậy mà thiết kế vẫn hỏng. Đúng cái khoảng cách đó — giữa những gì javac kiểm tra và những gì một thiết kế cam kết — là chủ đề của năm nguyên lý trong bài này.

SOLID thường được trình bày thành năm định nghĩa với năm ví dụ đồ chơi, nên phần lớn người đọc xong thì thuộc tên chứ không nhận ra được vi phạm trong code của chính mình. Vì vậy ở đây mỗi nguyên lý đều có một cặp before/after compile và chạy được trên OpenJDK 21.0.6, trong đó phần "before" là thứ một người có nghề hoàn toàn có thể viết ra. Và mỗi nguyên lý đều kèm một ghi chú thành thật về chỗ mà việc áp dụng nó không còn đáng với cái giá phải trả, bởi cả năm đều có thể bị lạm dụng thành một codebase với bốn mươi interface một method.

Năm ô chữ S, O, L, I và D dưới dòng chữ năm nguyên lý thiết kế và khi nào nên bỏ qua

Mọi dòng output, mọi exception và mọi thông báo của javac được trích dưới đây đều sinh ra từ việc compile và chạy đúng đoạn code hiển thị, không sửa gì, trên OpenJDK 21.0.6 cho arm64.

Năm cái tên, và thứ duy nhất cả năm cùng đo

SOLID là chữ viết tắt do Michael Feathers ghép lại từ năm nguyên lý mà Robert C. Martin viết rời rạc quanh những năm 2000. Tên gọi hơi trúc trắc, nhưng chữ viết tắt đã ăn sâu, và đó là vốn từ mọi team Java đang dùng khi review code.

ChữNguyên lýCâu hỏi nó đặt ra
SSingle ResponsibilityClass này có bao nhiêu lý do không liên quan để phải sửa?
OOpen/ClosedThêm một case có phải sửa những case đang chạy tốt không?
LLiskov SubstitutionCaller giữ supertype có đoán đúng chuyện gì sẽ xảy ra không?
IInterface SegregationCó chỗ nào bị ép implement một method nó không làm được không?
DDependency InversionPolicy phụ thuộc detail, hay cả hai cùng dựa vào abstraction?

Không nguyên lý nào nói về tốc độ. Cả năm đều nói về chi phí của một thay đổi, và chi phí đó đếm được: một thay đổi phải mở bao nhiêu file, bao nhiêu call site bị hỏng, phải dựng sẵn bao nhiêu thứ thì mới chạy nổi một mẩu logic. Chỗ nào bài này nói có cải thiện, chỗ đó sẽ nêu một con số bạn đếm lại được trong code.

Single Responsibility: một lý do để thay đổi

Responsibility ở đây không phải là "một việc mà class này làm". Nó là một lý do khiến ai đó phải mở file này ra sửa. Một class có ba lý do như vậy sẽ bị ba người khác nhau sửa vì ba động cơ chẳng liên quan gì nhau, và mỗi người đều có thể làm hỏng phần của hai người kia.

Class report dưới đây tính tổng tiền, format CSV rồi ghi ra đĩa. Nhìn qua thì chẳng ai bảo là vô lý.

import java.io.IOException;
import java.nio.file.*;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;

record Sale(String product, int qty, long unitCents) {
    long lineCents() { return qty * unitCents; }
}

class SalesReport {
    private final List<Sale> sales;
    private final Path out;
    private final DateTimeFormatter stamp;
    private final double taxRate;

    SalesReport(List<Sale> sales, Path out, DateTimeFormatter stamp, double taxRate) {
        this.sales = sales; this.out = out; this.stamp = stamp; this.taxRate = taxRate;
    }

    long generate() throws IOException {
        long net = 0;
        for (Sale s : sales) net += s.lineCents();
        long gross = Math.round(net * (1 + taxRate));

        StringBuilder csv = new StringBuilder("product,qty,cents\n");
        for (Sale s : sales) csv.append(s.product()).append(',')
                                .append(s.qty()).append(',')
                                .append(s.lineCents()).append('\n');
        csv.append("TOTAL,,").append(gross).append('\n');
        csv.append("generated,").append(stamp.format(LocalDate.of(2026, 10, 6))).append(",\n");

        Files.writeString(out, csv.toString());
        return gross;
    }
}

public class Before {
    public static void main(String[] args) throws Exception {
        List<Sale> sales = List.of(new Sale("keyboard", 2, 4500), new Sale("mouse", 3, 1900));

        // All I want is the tax arithmetic.
        SalesReport report = new SalesReport(
            sales, Path.of("report.csv"), DateTimeFormatter.ISO_DATE, 0.10);
        long gross = report.generate();

        System.out.println("gross cents          = " + gross);
        System.out.println("report.csv on disk?  = " + Files.exists(Path.of("report.csv")));
    }
}
gross cents          = 16170
report.csv on disk?  = true

Nhìn dòng thứ hai. Chỉ kiểm tra một quy tắc tính tiền mà lại ghi hẳn một file ra đĩa, vì tính toán và lưu trữ nằm chung một method. Và để lấy được con số đó tôi phải đưa vào một Path cùng một DateTimeFormatter, hai thứ chẳng liên quan gì tới tax. Constructor nhận bốn tham số; đúng một trong bốn tham gia vào kết quả tôi cần.

Tách theo lý do phải sửa, chứ không phải theo kiểu "mỗi danh từ một class":

import java.io.IOException;
import java.nio.file.*;
import java.util.List;

record Sale(String product, int qty, long unitCents) {
    long lineCents() { return qty * unitCents; }
}

/** Changes when a tax rule changes. Nothing else. */
final class SalesTotals {
    private final double taxRate;
    SalesTotals(double taxRate) { this.taxRate = taxRate; }

    long grossCents(List<Sale> sales) {
        long net = 0;
        for (Sale s : sales) net += s.lineCents();
        return Math.round(net * (1 + taxRate));
    }
}

/** Changes when the file format changes. Nothing else. */
final class CsvRenderer {
    String render(List<Sale> sales, long grossCents) {
        StringBuilder csv = new StringBuilder("product,qty,cents\n");
        for (Sale s : sales) csv.append(s.product()).append(',')
                                .append(s.qty()).append(',')
                                .append(s.lineCents()).append('\n');
        return csv.append("TOTAL,,").append(grossCents).append('\n').toString();
    }
}

/** Changes when reports move somewhere else. Nothing else. */
final class ReportStore {
    void save(Path where, String body) throws IOException { Files.writeString(where, body); }
}

public class After {
    public static void main(String[] args) throws IOException {
        List<Sale> sales = List.of(new Sale("keyboard", 2, 4500), new Sale("mouse", 3, 1900));

        long gross = new SalesTotals(0.10).grossCents(sales);
        System.out.println("gross cents          = " + gross);
        System.out.println("report.csv on disk?  = " + Files.exists(Path.of("report.csv")));

        // Producing an actual report is now a separate decision.
        new ReportStore().save(Path.of("report.csv"), new CsvRenderer().render(sales, gross));
        System.out.println("after saving         = " + Files.exists(Path.of("report.csv")));
    }
}
gross cents          = 16170
report.csv on disk?  = false
after saving         = true

Vẫn là 16170. Bốn khác biệt đếm được: constructor cho phần tính toán từ bốn tham số còn một; hỏi tổng tiền giờ ghi ra không file nào thay vì một; ba thay đổi trong tương lai mở ba file khác nhau thay vì đụng nhau trong một file; và chuyển report sang object storage không còn kéo theo việc compile lại thứ gì biết về tax.

Một class với ba lý do thay đổi ở bên trái, ba class mỗi cái một lý do ở bên phải

Cái bẫy là hiểu "single responsibility" thành "phải nhỏ". Nó không phải số dòng code. Một parser hai nghìn dòng với đúng một lý do để sửa là ổn; một class bốn mươi dòng vừa validate vừa gửi email thì không.

Open/Closed: thêm một case mà không sửa những case đang chạy

Open for extension, closed for modification. Bản diễn đạt thực sự dùng được: thêm một case thì không phải sửa những case đang chạy tốt.

Đây là chỗ nó cắn. Ba method, ba switch, cùng một tập case.

class ShippingCalculator {
    long costCents(String method, int grams) {
        switch (method) {
            case "STANDARD": return 500 + grams / 10;
            case "EXPRESS":  return 1200 + grams / 5;
            case "PICKUP":   return 0;
            default: throw new IllegalArgumentException("unknown method: " + method);
        }
    }

    int estimatedDays(String method, boolean international) {
        switch (method) {
            case "STANDARD": return international ? 12 : 4;
            case "EXPRESS":  return international ? 4 : 1;
            case "PICKUP":   return 0;
            default: throw new IllegalArgumentException("unknown method: " + method);
        }
    }

    boolean isAvailable(String method, int grams, boolean international) {
        switch (method) {
            case "STANDARD": return grams <= 30_000;
            case "EXPRESS":  return grams <= 5_000;
            case "PICKUP":   return !international;
            default: throw new IllegalArgumentException("unknown method: " + method);
        }
    }
}

public class Before {
    public static void main(String[] args) {
        ShippingCalculator c = new ShippingCalculator();
        for (String m : new String[] { "STANDARD", "EXPRESS", "PICKUP" }) {
            System.out.printf("%-9s %6d cents  %2d days  available=%b%n",
                m, c.costCents(m, 800), c.estimatedDays(m, false), c.isAvailable(m, 800, false));
        }
        System.out.println(c.costCents("DRONE", 800));
    }
}
STANDARD     580 cents   4 days  available=true
EXPRESS     1360 cents   1 days  available=true
PICKUP         0 cents   0 days  available=true
Exception in thread "main" java.lang.IllegalArgumentException: unknown method: DRONE
	at ShippingCalculator.costCents(Before.java:7)
	at Before.main(Before.java:37)

Thêm giao hàng bằng drone nghĩa là ba chỗ phải sửa trong đúng một file này — tôi đã sửa thật và đếm diff, ba dòng thêm vào ba method khác nhau. Phần tốn kém không nằm ở việc gõ. Nó nằm ở chỗ không có gì kiểm tra xem bạn đã sửa đủ ba hay chưa: tôi thêm DRONE vào riêng costCents, file compile trót lọt không một lời phàn nàn, rồi ném IllegalArgumentException: unknown method: DRONE lúc runtime từ estimatedDays, đúng cái switch bị bỏ sót. Compiler không giúp được, vì switch trên String chẳng biết tập đầy đủ gồm những gì.

Biến mỗi case thành một type thì cả ba switch biến mất:

import java.util.List;

record Parcel(int grams, boolean international) {}

interface ShippingMethod {
    String name();
    long costCents(Parcel p);
    int estimatedDays(Parcel p);
    boolean isAvailable(Parcel p);
}

final class Standard implements ShippingMethod {
    public String name() { return "STANDARD"; }
    public long costCents(Parcel p) { return 500 + p.grams() / 10; }
    public int estimatedDays(Parcel p) { return p.international() ? 12 : 4; }
    public boolean isAvailable(Parcel p) { return p.grams() <= 30_000; }
}

final class Express implements ShippingMethod {
    public String name() { return "EXPRESS"; }
    public long costCents(Parcel p) { return 1200 + p.grams() / 5; }
    public int estimatedDays(Parcel p) { return p.international() ? 4 : 1; }
    public boolean isAvailable(Parcel p) { return p.grams() <= 5_000; }
}

final class Pickup implements ShippingMethod {
    public String name() { return "PICKUP"; }
    public long costCents(Parcel p) { return 0; }
    public int estimatedDays(Parcel p) { return 0; }
    public boolean isAvailable(Parcel p) { return !p.international(); }
}

// Added months later. Nothing above this line was touched.
final class Drone implements ShippingMethod {
    public String name() { return "DRONE"; }
    public long costCents(Parcel p) { return 2500; }
    public int estimatedDays(Parcel p) { return 1; }
    public boolean isAvailable(Parcel p) { return !p.international() && p.grams() <= 2_000; }
}

public class After {
    static void quote(List<ShippingMethod> methods, Parcel p) {
        for (ShippingMethod m : methods) {
            if (!m.isAvailable(p)) continue;
            System.out.printf("%-9s %6d cents  %2d days%n",
                m.name(), m.costCents(p), m.estimatedDays(p));
        }
    }

    public static void main(String[] args) {
        // The one line that has to know the new class exists.
        List<ShippingMethod> methods =
            List.of(new Standard(), new Express(), new Pickup(), new Drone());

        quote(methods, new Parcel(800, false));
        System.out.println("---");
        quote(methods, new Parcel(9_000, true));
    }
}
STANDARD     580 cents   4 days
EXPRESS     1360 cents   1 days
PICKUP         0 cents   0 days
DRONE       2500 cents   1 days
---
STANDARD    1400 cents  12 days

Drone là một file mới. Không dòng nào trong Standard, Express, Pickup hay ShippingMethod bị đổi, và — phần mà các ví dụ đồ chơi hay bỏ qua — bạn không thể quên một method, vì thiếu một cái là Drone is not abstract and does not override abstract method isAvailable(Parcel) in ShippingMethod. Giờ compiler ép đúng thứ mà ba cái switch chỉ dám hy vọng.

Ba chỗ sửa switch trong một file ở bên trái, một file implementation mới ở bên phải

Nhưng phải nói cho chính xác là mình đã mua được gì. Vẫn phải có chỗ nào đó gọi tên Drone, và ở đây là dòng List.of(...) trong main — một dòng sửa trong một file cũ. Open for extension chưa bao giờ có nghĩa là "file này không bao giờ được sửa nữa"; nó có nghĩa là thêm một case thì không phải sửa nó. Bài viết nào đưa bạn một plugin registry rồi tuyên bố không sửa gì cả thì thực ra đã đẩy chỗ sửa đi nơi khác, thường là vào một file config mà chẳng compiler nào kiểm tra.

Khi nào sealed interface với exhaustive switch là câu trả lời tốt hơn

Bản polymorphic là lựa chọn mặc định đúng khi tập case thực sự mở — shipping method, payment provider, format export, bất cứ thứ gì mà một yêu cầu tương lai có thể thêm vào. Khi tập case là đóng, sealed interface cộng pattern matching switch là một lựa chọn hợp lệ và thường tốt hơn, còn giả vờ ngược lại là cách bạn có một AbstractCreditCardPaymentHandlerImpl cho ba case sẽ không bao giờ nhiều thêm.

sealed interface Payment permits Card, BankTransfer, StoreCredit {}

record Card(String last4) implements Payment {}
record BankTransfer(String iban) implements Payment {}
record StoreCredit(long cents) implements Payment {}

public class Sealed {
    static long feeCents(Payment p, long amountCents) {
        return switch (p) {
            case Card c         -> Math.round(amountCents * 0.029) + 30;
            case BankTransfer b -> 25;
            case StoreCredit s  -> 0;
        };
    }

    public static void main(String[] args) {
        System.out.println("card         = " + feeCents(new Card("4242"), 10_000));
        System.out.println("banktransfer = " + feeCents(new BankTransfer("DE89"), 10_000));
        System.out.println("storecredit  = " + feeCents(new StoreCredit(5_000), 10_000));
    }
}

Nó in ra 320, 250. Để ý là không có nhánh default: switch này exhaustive vì permits đã nói cho javac biết danh sách đầy đủ. Đó là cái đánh đổi. Thêm một case bây giờ sửa file này — và compiler bắt bạn sửa hết mọi file cần sửa:

sealed interface Payment permits Card, BankTransfer, StoreCredit, Crypto {}

record Crypto(String chain) implements Payment {}
Sealed.java:10: error: the switch expression does not cover all possible input values
        return switch (p) {
               ^
1 error

Lỗi đó chỉ thẳng vào switch expression, và bạn nhận một lỗi như vậy cho mỗi switch chưa xử lý hết trong chương trình. So với bản dùng String, thứ compile im lặng rồi hỏng trên production. Quy tắc ngón tay cái: tập case mở thì cần một interface với vài implementation; tập case đóng thì cần một sealed hierarchy và các switch exhaustive. Tính phí trên những payment method công ty bạn hỗ trợ là tập đóng. Shipping method thì không.

Liskov Substitution: subclass compile được và trả về kết quả sai

Cách phát biểu của Barbara Liskov nói về việc subtype phải dùng được ở mọi chỗ mong đợi supertype. Trên thực tế nó nghĩa là subclass phải giữ mọi lời hứa mà superclass đã đưa ra — kể cả những lời hứa nằm trong Javadoc mà compiler không bao giờ đọc.

Bài đầu tiên của khóa này đã cho thấy một vi phạm kiểu này mà chưa gọi tên nó. Đây là một vi phạm khác, và phổ biến hơn, vì nó sinh ra từ một tối ưu. EventLog ghi rõ rằng append trả về index để đọc lại được. RotatingEventLog giới hạn bộ nhớ bằng cách bỏ entry cũ nhất.

import java.util.ArrayList;
import java.util.List;

class EventLog {
    protected final List<String> entries = new ArrayList<>();

    /** Appends an entry and returns the index it can be read back at. */
    int append(String entry) {
        entries.add(entry);
        return entries.size() - 1;
    }

    String get(int index) { return entries.get(index); }
}

/** Keeps memory bounded by dropping the oldest entry. Compiles without a warning. */
class RotatingEventLog extends EventLog {
    private final int max;

    RotatingEventLog(int max) { this.max = max; }

    @Override
    int append(String entry) {
        entries.add(entry);
        if (entries.size() > max) entries.remove(0);
        return entries.size() - 1;
    }
}

public class Before {
    /** Written against EventLog, months before RotatingEventLog existed. */
    static void audit(EventLog log) {
        int begin = log.append("BEGIN");
        for (int i = 0; i < 4; i++) log.append("step " + i);
        int end = log.append("END");

        System.out.println(log.getClass().getSimpleName());
        System.out.println("  get(begin) = " + log.get(begin));
        System.out.println("  get(end)   = " + log.get(end));
        System.out.println("  correct?   = " + log.get(begin).equals("BEGIN"));
    }

    public static void main(String[] args) {
        audit(new EventLog());
        audit(new RotatingEventLog(3));
    }
}
EventLog
  get(begin) = BEGIN
  get(end)   = END
  correct?   = true
RotatingEventLog
  get(begin) = step 2
  get(end)   = END
  correct?   = false

audit không hề bị sửa và cũng không compile lại với thứ gì mới. Với subclass, nó trả về sai entry — không exception, không crash, chỉ là một chuỗi khác ở đúng chỗ trước đây là BEGIN. javac -Xlint:all không in ra bất cứ thứ gì cho chương trình này: bản override đúng tên, đúng tham số, đúng return type và có cả annotation @Override. Mọi luật mà ngôn ngữ kiểm tra được đều thỏa mãn. Lời hứa rằng append trả về một index ổn định không phải là luật mà ngôn ngữ kiểm tra được.

Đó là hình dạng của gần như mọi vi phạm Liskov đáng bắt: không phải một subclass compile hỏng, mà một subclass lặng lẽ thu hẹp thứ mà supertype đã hứa. Siết chặt precondition (từ chối input mà cha chấp nhận), nới lỏng postcondition (trả về ít hơn mức cha bảo đảm) và ném ra exception mà cha không hề khai báo là ba dạng thường gặp.

Một contract mà cả hai type đều giữ được

@Override không phải là cách sửa, thêm một kiểm tra lúc runtime cũng vậy. Cách sửa là nhận ra contract gốc chưa bao giờ đúng với chuyện xoay vòng, rồi viết một contract mà cả hai type đều giữ được: append trả về một id không bao giờ bị dùng lại, còn đọc thì trả về entry đó hoặc không gì cả.

import java.util.LinkedHashMap;
import java.util.Optional;

/**
 * A contract both implementations can actually keep:
 *   append returns an id that is never reused,
 *   read returns that entry or nothing.
 */
interface EventLog {
    long append(String entry);
    Optional<String> read(long id);
}

final class ArrayEventLog implements EventLog {
    private final LinkedHashMap<Long, String> byId = new LinkedHashMap<>();
    private long next = 0;

    public long append(String entry) {
        long id = next++;
        byId.put(id, entry);
        return id;
    }

    public Optional<String> read(long id) { return Optional.ofNullable(byId.get(id)); }
}

final class RotatingEventLog implements EventLog {
    private final LinkedHashMap<Long, String> byId = new LinkedHashMap<>();
    private final int max;
    private long next = 0;

    RotatingEventLog(int max) { this.max = max; }

    public long append(String entry) {
        long id = next++;
        byId.put(id, entry);
        if (byId.size() > max) byId.remove(byId.keySet().iterator().next());
        return id;
    }

    public Optional<String> read(long id) { return Optional.ofNullable(byId.get(id)); }
}

public class After {
    static void audit(EventLog log) {
        long begin = log.append("BEGIN");
        for (int i = 0; i < 4; i++) log.append("step " + i);
        long end = log.append("END");

        System.out.println(log.getClass().getSimpleName());
        System.out.println("  read(begin) = " + log.read(begin).orElse("<gone>"));
        System.out.println("  read(end)   = " + log.read(end).orElse("<gone>"));
        System.out.println("  wrong data? = "
            + log.read(begin).filter(s -> !s.equals("BEGIN")).isPresent());
    }

    public static void main(String[] args) {
        audit(new ArrayEventLog());
        audit(new RotatingEventLog(3));
    }
}
ArrayEventLog
  read(begin) = BEGIN
  read(end)   = END
  wrong data? = false
RotatingEventLog
  read(begin) = <gone>
  read(end)   = END
  wrong data? = false

Rotating log vẫn bỏ entry cũ — đó vốn là mục đích của nó — nhưng giờ nó nói ra điều đó theo cách caller nhìn thấy được, và wrong data?false ở cả hai. Contract yếu hơn mới là contract thành thật. Để ý thay đổi về cấu trúc: extends thành implements, và không implementation nào kế thừa gì từ cái còn lại. Khi hai type dùng chung một interface nhưng không chung một lời hứa, chúng chưa bao giờ là cha con.

Interface Segregation: method không ai implement nổi

Một interface quá béo đẩy việc sang cho những implementation không làm nổi. Triệu chứng nhìn thấy được là một method body tồn tại chỉ để ném exception.

Storage trông hoàn toàn hợp lý cho tới khi có thứ chỉ đọc phải implement nó — asset đóng gói sẵn, một volume mount read-only, một artifact đã ký.

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.*;
import java.util.HashMap;
import java.util.Map;

interface Storage {
    String read(String key);
    void write(String key, String value);
    void delete(String key);
}

final class MemoryStorage implements Storage {
    private final Map<String, String> map = new HashMap<>();
    public String read(String key) { return map.get(key); }
    public void write(String key, String value) { map.put(key, value); }
    public void delete(String key) { map.remove(key); }
}

/** Assets shipped inside the build. Two of the three methods have no meaning here. */
final class BundledStorage implements Storage {
    private final Path root;

    BundledStorage(Path root) { this.root = root; }

    public String read(String key) {
        try { return Files.readString(root.resolve(key)); }
        catch (IOException e) { throw new UncheckedIOException(e); }
    }

    public void write(String key, String value) {
        throw new UnsupportedOperationException("BundledStorage is read-only");
    }

    public void delete(String key) {
        throw new UnsupportedOperationException("BundledStorage is read-only");
    }
}

public class Before {
    /** Copies a key into cold storage, then removes the original. */
    static void archive(Storage from, String key, Storage cold) {
        cold.write(key, from.read(key));
        from.delete(key);
    }

    public static void main(String[] args) {
        Storage cold = new MemoryStorage();
        Storage bundled = new BundledStorage(Path.of("assets"));

        System.out.println("read = " + bundled.read("app.properties").lines().findFirst().get());
        archive(bundled, "app.properties", cold);
    }
}
read = app.name=vnntools
Exception in thread "main" java.lang.UnsupportedOperationException: BundledStorage is read-only
	at BundledStorage.delete(Before.java:36)
	at Before.archive(Before.java:44)
	at Before.main(Before.java:52)

Hãy đọc kỹ cái hỏng này, vì nó tệ hơn mức "nó ném exception". Bước copy sang cold storage đã thành công trước khi delete nổ, nên thao tác đang dang dở: dữ liệu giờ nằm ở hai nơi còn chương trình thì nằm dưới sàn. Một method signature hứa một thứ mà object không giao được, và cái giá phải trả lúc runtime, ngay giữa một thao tác hai bước.

Phần thưởng là lỗi compile thay vì lỗi runtime

Tách interface đúng theo đường mà các implementation thực sự chia ra. Đọc và ghi là hai khả năng khác nhau, nên chúng là hai interface, với cái writable extends cái readable vì thứ gì ghi được thì cũng đọc được.

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.*;
import java.util.HashMap;
import java.util.Map;

interface ReadableStorage {
    String read(String key);
}

interface WritableStorage extends ReadableStorage {
    void write(String key, String value);
    void delete(String key);
}

final class MemoryStorage implements WritableStorage {
    private final Map<String, String> map = new HashMap<>();
    public String read(String key) { return map.get(key); }
    public void write(String key, String value) { map.put(key, value); }
    public void delete(String key) { map.remove(key); }
}

/** Implements only what it can honour. Nothing throws for being unimplementable. */
final class BundledStorage implements ReadableStorage {
    private final Path root;

    BundledStorage(Path root) { this.root = root; }

    public String read(String key) {
        try { return Files.readString(root.resolve(key)); }
        catch (IOException e) { throw new UncheckedIOException(e); }
    }
}

public class After {
    static void archive(WritableStorage from, String key, WritableStorage cold) {
        cold.write(key, from.read(key));
        from.delete(key);
    }

    /** Only reads, so it accepts anything that can read. */
    static String firstLine(ReadableStorage s, String key) {
        return s.read(key).lines().findFirst().orElse("");
    }

    public static void main(String[] args) {
        WritableStorage hot = new MemoryStorage();
        WritableStorage cold = new MemoryStorage();
        BundledStorage bundled = new BundledStorage(Path.of("assets"));

        hot.write("session.txt", "user=42");
        archive(hot, "session.txt", cold);

        System.out.println("cold  = " + cold.read("session.txt"));
        System.out.println("hot   = " + hot.read("session.txt"));
        System.out.println("asset = " + firstLine(bundled, "app.properties"));
    }
}
cold  = user=42
hot   = null
asset = app.name=vnntools

Và giờ cái sai từng làm dở dang một lần archive không còn viết ra được nữa:

Bad.java:7: error: incompatible types: BundledStorage cannot be converted to WritableStorage
        After.archive(bundled, "app.properties", cold);
                      ^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

Đó là toàn bộ cái lợi của interface segregation, nói bằng con số: xóa hẳn một lớp lỗi runtime, và số body ném UnsupportedOperationException từ hai xuống không. firstLine cũng trở nên hữu ích hơn mà không phải sửa gì — nó nhận ReadableStorage, nên mọi store trong chương trình đều truyền vào được.

Giờ tới phần thành thật. JDK không làm như vậy, và làm thế có chủ đích. java.util.Collection ghi rõ add là một "optional operation", và List.of("a", "b").add("c") ném UnsupportedOperationException từ ImmutableCollections.uoe — đúng cái mùi vừa mô tả ở trên, được ship trong standard library bởi những người biết rõ mình đang làm gì. Tách Collection thành hai nửa readable và mutable sẽ nhân đôi số interface trên toàn bộ thư viện và bắt mọi method signature trong mọi codebase phải chọn phe. Họ chấp nhận lỗi runtime để giữ một type duy nhất. Khi interface của bạn có một implementation lấn cấn trên mười hai cái, họ đã chọn đúng; khi một nửa số implementation của bạn đang ném exception, bạn đang gặp vấn đề khác.

Dependency Inversion: class không chạy nổi nếu thiếu file

Hai mệnh đề, và mệnh đề thứ hai mới là chỗ người ta hay bỏ sót. Policy ở tầng cao không nên phụ thuộc detail ở tầng thấp; và cả hai nên dựa vào một abstraction. Abstraction đó thuộc về policy, không thuộc về detail.

Class dưới đây tự đọc file config của chính nó. Đây là một hình dạng cực kỳ phổ biến.

import java.io.IOException;
import java.nio.file.*;
import java.util.Properties;

/** Reads its own configuration. The dependency on the filesystem is hidden inside. */
class FeatureFlags {
    private final Properties props = new Properties();

    FeatureFlags() throws IOException {
        try (var in = Files.newInputStream(Path.of("flags.properties"))) {
            props.load(in);
        }
    }

    boolean enabled(String flag) {
        return Boolean.parseBoolean(props.getProperty(flag, "false"));
    }
}

public class Before {
    public static void main(String[] args) throws IOException {
        // The one thing worth checking: an unknown flag is off.
        FeatureFlags flags = new FeatureFlags();
        System.out.println("dark-mode = " + flags.enabled("dark-mode"));
        System.out.println("no-such   = " + flags.enabled("no-such"));
    }
}

Chạy nó trong một thư mục không có flags.properties thì bạn không chạm tới được phần logic:

Exception in thread "main" java.nio.file.NoSuchFileException: flags.properties
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)

Stack trace thật còn chạy qua tám frame nội bộ của JDK nữa trước khi tới at FeatureFlags.<init>(Before.java:10). Không có cách nào kiểm tra enabled("no-such") trả về gì nếu không có một file tồn tại trên đĩa ở đúng đường dẫn mà class này tự chọn cho mình. Mọi phép kiểm tra class này đều là kiểm tra filesystem, và class này vô dụng ở mọi môi trường deploy giữ config ở chỗ khác.

Cái gì mới được tính là abstraction ở đây

Không phải "một object Properties". Abstraction ở đây là một interface nhỏ mô tả thứ FeatureFlags cần — đúng một method — khai báo ngay cạnh FeatureFlags vì nó thuộc về policy, và được implement bởi thứ nào biết về file.

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.*;
import java.util.Map;
import java.util.Properties;

/** What FeatureFlags needs, stated by FeatureFlags — not by the filesystem. */
interface FlagSource {
    String get(String flag);
}

final class FileFlagSource implements FlagSource {
    private final Properties props = new Properties();

    FileFlagSource(Path path) {
        try (var in = Files.newInputStream(path)) { props.load(in); }
        catch (IOException e) { throw new UncheckedIOException(e); }
    }

    public String get(String flag) { return props.getProperty(flag); }
}

final class MapFlagSource implements FlagSource {
    private final Map<String, String> map;

    MapFlagSource(Map<String, String> map) { this.map = map; }

    public String get(String flag) { return map.get(flag); }
}

final class FeatureFlags {
    private final FlagSource source;

    FeatureFlags(FlagSource source) { this.source = source; }

    boolean enabled(String flag) {
        String v = source.get(flag);
        return v != null && Boolean.parseBoolean(v);
    }
}

public class After {
    public static void main(String[] args) {
        // The same check, with no file anywhere.
        FeatureFlags flags = new FeatureFlags(new MapFlagSource(Map.of("dark-mode", "true")));

        System.out.println("dark-mode      = " + flags.enabled("dark-mode"));
        System.out.println("no-such        = " + flags.enabled("no-such"));
        System.out.println("file on disk?  = " + Files.exists(Path.of("flags.properties")));

        // Only the wiring at startup knows a file is involved.
        Path p = Path.of("flags.properties");
        if (Files.exists(p)) {
            System.out.println("from the file  = "
                + new FeatureFlags(new FileFlagSource(p)).enabled("dark-mode"));
        }
    }
}
dark-mode      = true
no-such        = false
file on disk?  = false

FeatureFlags bây giờ compile mà không cần java.nio.file trong tầm nhìn. Phép kiểm tra trước đây cần một file, một thư mục và một bước dọn dẹp giờ còn ba dòng và không đụng vào gì. Chạy chính chương trình đó khi có flags.properties thì nhánh cuối in ra from the file = true, nên đường đi qua file vẫn được chạy — chỉ là nó không còn là đường duy nhất.

Mũi tên compile-time từ FeatureFlags trỏ vào filesystem, rồi đảo chiều qua một interface

Mũi tên chính là toàn bộ ý tưởng. Trước đó, FeatureFlags trỏ xuống Files.newInputStream và qua đó trỏ vào một đường dẫn cụ thể trên đĩa. Sau đó, FeatureFlags trỏ vào FlagSource, còn FileFlagSource trỏ lên chính interface đó. Detail ở tầng thấp giờ dựa vào vốn từ của policy ở tầng cao, và đó chính là thứ mà chữ "inversion" gọi tên.

Khi nào các nguyên lý này không còn đáng

Cả năm đều có thể bị áp dụng tới mức gây hại, và một codebase bị over-engineer nhân danh SOLID còn khó sửa hơn cái nó thay thế. Bốn type ở chỗ trước đây là một method, và kết quả y hệt:

// Every principle applied, to a calculation that has one rule and always will.
interface DiscountRate { double rate(); }
final class TenPercent implements DiscountRate { public double rate() { return 0.10; } }

interface TotalCalculator { long total(long netCents, DiscountRate d); }
final class DefaultTotalCalculator implements TotalCalculator {
    public long total(long netCents, DiscountRate d) {
        return Math.round(netCents * (1 - d.rate()));
    }
}

public class TooFar {
    // What all of that replaced.
    static long total(long netCents) { return Math.round(netCents * 0.90); }

    public static void main(String[] args) {
        TotalCalculator calc = new DefaultTotalCalculator();
        System.out.println("four types  = " + calc.total(16_170, new TenPercent()));
        System.out.println("one method  = " + total(16_170));
    }
}
four types  = 14553
one method  = 14553

Cùng một con số, thêm bốn file .class, và một người đọc giờ phải mở ba file mới tìm ra một phép nhân. Chỗ tôi sẽ cố tình không áp dụng từng nguyên lý:

Nguyên lýBỏ qua khi
Single ResponsibilityClass là record hay DTO không có hành vi, hoặc "hai responsibility" luôn thay đổi cùng nhau vì cùng một lý do.
Open/ClosedTập case thực sự đóng. Sealed hierarchy với switch exhaustive cho bạn sự đầy đủ được compiler kiểm tra, thứ mà polymorphism không có.
Liskov SubstitutionKhông bao giờ — vi phạm nó chẳng được lợi gì. Nhưng đây là lý do để ưu tiên composition hơn inheritance, không phải lý do để thêm interface.
Interface SegregationMột implementation lấn cấn giữa nhiều cái, mà tách ra thì nhân đôi số type cho tất cả mọi người. Đây đúng là lựa chọn JDK đã làm với Collection.
Dependency InversionDependency đó thuần túy và tất định. Inject một StringBuilder, một ArrayList hay Math.round chẳng được gì mà tốn thêm một tham số constructor.

⚠️ Tín hiệu đáng tin nhất là thay đổi mà bạn đang phòng trước đã từng xảy ra bao giờ chưa. "Biết đâu sau này mình đổi sang database khác" đã đẻ ra nhiều lớp abstraction vô dụng hơn bất kỳ câu nào khác trong nghề. Chỉ invert những dependency bạn đã thực sự phải thay: clock, filesystem, network, randomness, bất cứ thứ gì nói chuyện với một hệ thống bạn không kiểm soát.

Thứ tự áp dụng cũng quan trọng. Dependency Inversion và Single Responsibility có lợi gần như ngay lập tức và trên gần như mọi codebase. Open/Closed có lợi từ lần thêm case thứ hai, chứ không sớm hơn. Interface Segregation chỉ có lợi khi bạn có những implementation thực sự khác nhau về khả năng. Liskov thì không phải một lần refactor gì cả — nó là luật bạn kiểm tra mỗi lần viết extends.

FAQ

SOLID trong Java là viết tắt của những gì?

Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation và Dependency Inversion. Đó là năm nguyên lý thiết kế hướng đối tượng riêng biệt được gom dưới một chữ viết tắt, không phải một framework hay một checklist để tick. Không có gì trong ngôn ngữ Java bắt buộc chúng: đây là tính chất của một thiết kế mà javac sẽ vui vẻ compile theo cả hai hướng.

SOLID còn hợp thời với Java hiện đại không?

Các vấn đề thì còn, còn một số câu trả lời đã đổi. Record xóa bớt rất nhiều class từng vi phạm Single Responsibility một cách vô tình, sealed interface cộng pattern matching cho Open/Closed một lựa chọn được compiler kiểm tra thay cho polymorphism khi tập case đóng, và lambda làm interface một method rẻ đến mức Interface Segregation tốn ít hơn trước. Thứ không đổi là câu hỏi nền: thay đổi tiếp theo tốn bao nhiêu?

Liskov Substitution khác gì với việc override thông thường?

Override là một cơ chế của ngôn ngữ, được compiler kiểm tra: cùng tên, tham số tương thích, return type tương thích. Substitution là một tính chất thiết kế mà không compiler nào kiểm tra. RotatingEventLog trong bài này override đúng theo mọi luật javac biết, có annotation @Override, không sinh warning nào dưới -Xlint:all, mà vẫn trả cho caller sai entry, vì superclass đã ghi ra một lời hứa subclass không giữ nổi.

Làm sao biết một class vi phạm Single Responsibility?

Hỏi xem ai sẽ là người yêu cầu sửa nó. Nếu câu trả lời là những người khác nhau với động cơ khác nhau — team tài chính vì quy tắc tax, team dữ liệu vì format file, team hạ tầng vì chỗ lưu file — thì đó là ba responsibility trong một file. Số dòng code là thước đo tệ: một parser hai nghìn dòng với một lý do để sửa là ổn, còn một class bốn mươi dòng vừa validate vừa gửi email thì không.

Dependency Inversion có bắt buộc dùng framework dependency injection không?

Không. Mọi ví dụ trong bài này đều invert dependency bằng đúng một tham số constructor và không gì khác. Framework tự động hóa phần nối dây khi bạn có hàng trăm object phải nối; nó không làm hộ bạn việc invert, và thêm nó vào một chương trình nhỏ là mua thêm sự phức tạp của config để đổi lấy không gì cả.

Áp dụng SOLID có thể làm code tệ đi không?

Có, rất dễ, và kiểu hỏng thì nhận ra được: interface một method với đúng một implementation đặt tên theo nó, class bọc quanh những object chỉ từng được tạo theo đúng một cách, và những lớp abstraction dựng sẵn cho một database thứ hai không bao giờ tới. Mỗi thứ đó là một nguyên lý áp dụng cho một thay đổi không bao giờ xảy ra. Phép thử là sự linh hoạt bạn đang thêm có khớp với một thay đổi bạn đã thực sự phải làm hay không.

Nên học nguyên lý SOLID nào trước?

Dependency Inversion, vì đây là cái bạn thấy lợi ngay: một class nhận interface thay vì tự mở file thì chạy và kiểm tra được độc lập, và điều đó đổi cách bạn làm việc ngay trong ngày bạn áp dụng nó. Kế đến là Single Responsibility, cũng vì lý do đó. Open/Closed và Interface Segregation có lợi từ lần thay đổi thứ hai và thứ ba, nên dễ cảm nhận hơn sau khi bạn đã sống với một codebase một thời gian.

Kết luận

Năm nguyên lý là năm câu trả lời cho một câu hỏi: thay đổi tiếp theo tốn bao nhiêu? Single Responsibility đếm xem bao nhiêu lý do không liên quan cùng mở một file. Open/Closed hỏi một case mới có phải sửa những case đang chạy tốt không. Liskov hỏi caller đang giữ supertype có còn đoán đúng chuyện gì xảy ra không. Interface Segregation hỏi có implementation nào bị bắt hứa thứ nó không làm được không. Dependency Inversion hỏi mũi tên đang trỏ vào một detail hay vào một abstraction thuộc về policy.

Không cái nào là luật phải theo vô điều kiện. Mọi ví dụ ở đây đều đáng vì có một thay đổi cụ thể nhiều khả năng sẽ tới — một shipping method mới, một format report mới, config chuyển ra khỏi file. Áp lên đoạn code mà thay đổi đó không bao giờ tới, cũng những kỹ thuật ấy đẻ ra bốn type ở chỗ một method là đủ, và cho ra đúng con số y hệt ở cuối. Điều cần cân nhắc không phải "mình đã áp dụng SOLID chưa"; mà là "mình đang mua bảo hiểm cho thay đổi nào, và nó đã bao giờ xảy ra chưa".

Bài tiếp theo trong series: các design pattern thường gặp trong Java — Singleton, Factory, Builder, Observer và Strategy. Vài cái trong số đó chính là những nguyên lý này được triển khai thành một hình dạng có tên và dùng lại được, nên cái interface-với-nhiều-implementation bạn đã viết hai lần trong bài này sẽ thấy quen ngay khi nó xuất hiện kèm một cái tên.

Bài viết liên quan

[Advanced Java] Comparable vs Comparator trong Java: Natural Ordering và Sắp Xếp Tùy Chỉnh

Comparable và Comparator trên OpenJDK 21: contract đầy đủ của compareTo, API factory và combinator của Comparator, vì sao reversed đảo mọi key đã compose, bug trừ hai số int, IllegalArgumentException từ TimSort chỉ nổ với input lớn, xử lý null và tính stable của sort.

[Advanced Java] Stream API trong Java: map, filter, reduce và collect

Stream API của Java trên OpenJDK 21: pipeline gồm source, intermediate và terminal, tính lazy được chứng minh bằng trace println xen kẽ, map, filter, cả ba overload của reduce, collect cùng bộ Collectors, primitive stream và chi phí allocation của boxing, và các bẫy quanh peek, findAny, lambda có state cùng parallelStream.

[Java Basics] Đệ quy trong Java: cách hoạt động và khi nào nên dùng

Đệ quy trong Java hoạt động ra sao: base case và recursive case, trace factorial theo từng frame, StackOverflowError thật khi thiếu base case, độ sâu đệ quy và -Xss, vì sao Fibonacci naive cần 2692537 lời gọi cho fib(30) trong khi memoisation chỉ cần 59, đệ quy so với vòng lặp, và vì sao JVM không tối ưu tail call.

[Advanced Java] Lambda Expression trong Java: cú pháp, target typing và method reference

Lambda expression trong Java trên OpenJDK 21: đầy đủ các dạng cú pháp kể cả var parameter, target typing chứng minh bằng cách gán một đoạn text cho ba interface, luật effectively final khi capture kèm error thật của javac, this bên trong lambda, bốn loại method reference, và vì sao bound reference đánh giá receiver ngay lập tức.