Nguyên lý SOLID
Xem thửMột lớp OrderService 2.000 dòng
OrderService của gdev-shop bắt đầu nhỏ. Hai năm sau nó dài 2.000 dòng: tạo đơn, tính thuế, tính giảm giá, gọi cổng thanh toán, gửi email, xuất hóa đơn PDF, ghi log kiểm toán. Mỗi lần ai đó sửa cách tính thuế, hai tính năng không liên quan hỏng theo. Muốn thêm phương thức thanh toán mới phải mở file này ra và sửa giữa một rừng if. Không ai dám viết test vì phải dựng cả thế giới để chạy một hàm.
SOLID là năm nguyên lý thiết kế hướng đối tượng giúp tránh đúng loại hỗn độn này. Chúng không phải luật tuyệt đối mà là hướng dẫn nhận ra mùi thiết kế xấu và cách sửa. Mỗi chữ cái là một nguyên lý.
S: Single Responsibility (đơn trách nhiệm)
Một lớp chỉ nên có một lý do để thay đổi.
Lý do để thay đổi thường đến từ một nhóm người hoặc một mối quan tâm. OrderService có nhiều lý do: kế toán đổi cách tính thuế, đội marketing đổi quy tắc giảm giá, đội hạ tầng đổi nhà cung cấp email. Mỗi thay đổi đều chạm vào cùng một lớp.
Cách sửa: tách theo trách nhiệm.
public class OrderService {
private final TaxCalculator taxCalculator;
private final DiscountPolicy discountPolicy;
private final PaymentGateway paymentGateway;
private final NotificationSender notificationSender;
public Order place(PlaceOrderCommand command) {
Order order = Order.create(command);
order.applyDiscount(discountPolicy.discountFor(order));
order.applyTax(taxCalculator.taxFor(order));
paymentGateway.charge(order);
notificationSender.send(Notification.orderConfirmed(order));
return order;
}
}
OrderService giờ chỉ điều phối quy trình. Thuế, giảm giá, thanh toán, thông báo đều nằm ở các lớp riêng. Đổi cách tính thuế chỉ sửa TaxCalculator. Lưu ý: "một trách nhiệm" không có nghĩa "một phương thức". Nó nghĩa là một lý do thay đổi. Tách quá nhỏ làm hàng chục lớp vụn vặt, cũng là một dạng sai.
O: Open/Closed (mở để mở rộng, đóng để sửa đổi)
Có thể thêm hành vi mới mà không phải sửa mã cũ đã chạy tốt.
Ví dụ ở bài OOP: thêm phương thức thanh toán mới bằng cách viết lớp mới cài đặt PaymentMethod, thay vì sửa chuỗi if. Mã cũ đóng với sửa đổi (không đụng đến, nên không có nguy cơ phá vỡ), phần mở rộng thì mở (thêm lớp mới).
Điều kiện để làm được: có điểm mở rộng (một giao diện) đúng chỗ có khả năng thay đổi. Không cần chuẩn bị điểm mở rộng cho mọi thứ: chỉ khi một loại thay đổi thực sự lặp lại (thêm phương thức thanh toán, thêm quy tắc giảm giá) mới nên tạo giao diện.
L: Liskov Substitution (thay thế Liskov)
Một đối tượng của lớp con phải có thể thay thế đối tượng của lớp cha mà chương trình vẫn đúng.
Ví dụ vi phạm: Voucher có phương thức apply(order). Lớp con ExpiredVoucher extends Voucher ném ngoại lệ trong apply. Mã dùng Voucher chạy đúng với mọi Voucher khác sẽ bất ngờ nổ khi gặp lớp con này. Lớp con phá vỡ hợp đồng của lớp cha.
public interface Voucher {
Money discountFor(Order order);
}
public class ExpiredVoucher implements Voucher {
public Money discountFor(Order order) {
return Money.zero();
}
}
Cách sửa: lớp con tuân thủ hợp đồng: ở đây hợp đồng là "trả về số tiền giảm" và voucher hết hạn trả 0 thay vì ném ngoại lệ không ai lường trước. Quy tắc nhớ: lớp con không được đòi hỏi nhiều hơn (điều kiện đầu vào chặt hơn) và không được hứa ít hơn (kết quả yếu hơn) lớp cha. Khi gặp tình huống "lớp con phải làm khác hẳn", đó là dấu hiệu quan hệ kế thừa sai, nên đổi sang thành phần.
I: Interface Segregation (tách giao diện)
Đừng ép một lớp phụ thuộc vào những phương thức nó không dùng.
Giả sử một giao diện lớn:
public interface OrderOperations {
Order create(PlaceOrderCommand command);
void cancel(long orderId);
void refund(long orderId);
byte[] exportInvoicePdf(long orderId);
List<Order> searchForReport(ReportFilter filter);
}
Màn hình khách chỉ cần create và cancel. Báo cáo chỉ cần searchForReport. Nhưng mọi bên phụ thuộc vào cả năm phương thức, bên cài đặt phải cài cả năm, và thay đổi exportInvoicePdf có thể ảnh hưởng bên chỉ dùng create.
Đang tải Animation…
Đang tải Animation…
Đang tải Animation…
Cách đọc hình
Giao diện lớn được tách thành ba giao diện nhỏ OrderCreation, OrderReporting, InvoiceExport. OrderServiceImpl vẫn có thể cài đặt cả ba. Nhưng CheckoutScreen chỉ phụ thuộc OrderCreation và ReportJob chỉ phụ thuộc OrderReporting: mỗi bên chỉ thấy phần nó cần. Thay đổi việc xuất PDF không ảnh hưởng màn hình thanh toán. Và khi kiểm thử CheckoutScreen, bản giả chỉ cần cài hai phương thức thay vì năm.
D: Dependency Inversion (đảo ngược phụ thuộc)
Các mô đun cấp cao không nên phụ thuộc vào mô đun cấp thấp. Cả hai nên phụ thuộc vào giao diện.
Đây chính là nội dung bài về mô đun và giao diện: OrderService (nghiệp vụ, cấp cao) phụ thuộc vào NotificationSender (giao diện), còn ProviderAEmailSender (chi tiết, cấp thấp) cũng phụ thuộc vào giao diện đó bằng cách cài đặt nó. Hướng phụ thuộc đi vào phía nghiệp vụ. Lợi ích đã thấy: đổi nhà cung cấp mà không sửa nghiệp vụ, kiểm thử bằng bản giả. Kỹ thuật phối hợp: dependency injection.
Năm nguyên lý trong một bảng
| Nguyên lý | Nhận ra vi phạm khi | Hướng sửa |
|---|---|---|
| S Single Responsibility | Một lớp thay đổi vì nhiều lý do khác nhau, dài hàng nghìn dòng | Tách theo trách nhiệm |
| O Open/Closed | Thêm tính năng mới phải sửa nhiều chỗ, chuỗi if/else theo loại | Giao diện cộng nhiều cài đặt |
| L Liskov Substitution | Lớp con ném ngoại lệ hoặc làm khác hợp đồng của lớp cha | Tuân thủ hợp đồng, hoặc đổi kế thừa thành thành phần |
| I Interface Segregation | Phải cài phương thức không cần, phụ thuộc vào phần không dùng | Tách giao diện nhỏ theo nhu cầu bên dùng |
| D Dependency Inversion | Lớp nghiệp vụ new trực tiếp thư viện hoặc hạ tầng cụ thể | Phụ thuộc vào giao diện, tiêm phụ thuộc |
SOLID cũng có cái giá
- Lạm dụng dẫn đến quá nhiều lớp và giao diện vụn vặt, mỗi thay đổi nhỏ chạm nhiều file. Hãy áp dụng khi bạn thực sự thấy mùi, không áp dụng máy móc từ ngày đầu.
- Khó đọc với người mới nếu mọi thứ bị chia nhỏ và gián tiếp: để hiểu một luồng phải nhảy qua nhiều lớp.
- Các nguyên lý đôi khi mâu thuẫn nhau hoặc với sự đơn giản (xem KISS và YAGNI ở các bài sau). Cần phán đoán.
- SOLID là về cấu trúc mã trong một thành phần. Nó không thay thế các quyết định kiến trúc ở mức hệ thống (ranh giới service, nơi đặt dữ liệu).
Những hiểu lầm phổ biến
- "Single Responsibility nghĩa là mỗi lớp một phương thức." Là một lý do thay đổi, không phải một hàm.
- "Open/Closed nghĩa là không bao giờ sửa mã cũ." Sửa lỗi hay đổi yêu cầu vẫn phải sửa. Nguyên lý nói về việc thêm hành vi mới không nên buộc sửa mã cũ.
- "Cứ tạo giao diện cho mọi lớp là SOLID." Giao diện không có lý do (một cài đặt, không đổi, không cần kiểm thử độc lập) là lạm dụng.
- "SOLID đảm bảo thiết kế tốt." Đó là kim chỉ nam, vẫn cần phán đoán theo bài toán.
Must Know, Good to Know, Advanced
- Must Know: năm nguyên lý và ý nghĩa của từng nguyên lý; nhận ra mùi vi phạm qua ví dụ; Dependency Inversion gắn với dependency injection; lạm dụng cũng là lỗi.
- Good to Know: Liskov nói về hợp đồng; tách giao diện theo nhu cầu bên dùng; SOLID ở mức mã chứ không thay quyết định kiến trúc.
- Advanced: các nguyên lý bổ sung về gói (cohesion, coupling), Law of Demeter. Chỉ cần biết rằng có thêm các hướng dẫn thiết kế.
Tự kiểm tra
OrderService2.000 dòng vi phạm nguyên lý nào, và cách tách?ExpiredVoucherném ngoại lệ trongdiscountForvi phạm nguyên lý nào, và cách sửa?- Vì sao tách giao diện lớn thành nhiều giao diện nhỏ giúp kiểm thử màn hình thanh toán dễ hơn?
Đáp án gợi ý
- Một lớp
OrderService2.000 dòng vi phạm Single Responsibility (nhiều lý do thay đổi: tính giá, kho, thanh toán, thông báo, lưu trữ) và thường kéo theo vi phạm Dependency Inversion. Cách tách: tách theo trách nhiệm thành các lớp nhỏ (PricingService,InventoryService,PaymentGateway,NotificationSender,OrderRepository), vàOrderServicechỉ điều phối chúng qua giao diện. - Vi phạm Liskov Substitution: lớp con
ExpiredVoucherném ngoại lệ ởdiscountFortrong khi hợp đồng của lớp cha là trả về một số tiền giảm, nên nơi dùngVoucherbị bất ngờ khi nhận lớp con. Cách sửa: trả về 0 (không giảm) hoặc dùng kiểu trả về thể hiện "không áp dụng được", để mọiVouchertuân cùng hợp đồng. - Giao diện lớn buộc màn hình thanh toán phải phụ thuộc cả các phương thức không dùng, và khi kiểm thử phải tạo bản giả cho toàn bộ giao diện. Tách thành giao diện nhỏ theo nhu cầu (Interface Segregation) giúp bản giả của test chỉ cần cài những phương thức màn hình thực sự dùng, nên test ngắn và ít bị vỡ.
Nếu chỉ nhớ 3 điều
- SOLID: một lý do thay đổi mỗi lớp, thêm hành vi không sửa mã cũ, lớp con thay thế được lớp cha, giao diện nhỏ theo nhu cầu, phụ thuộc vào giao diện.
- Đây là cách nhận ra mùi thiết kế (lớp phình to, chuỗi if theo loại, lớp con phá hợp đồng) và cách sửa.
- Đừng lạm dụng: áp dụng khi thấy vấn đề thực sự; SOLID là kim chỉ nam mức mã, không thay quyết định kiến trúc.