Liskov Substitution Principle (LSP)
Chương 4 đã đưa FeeCalculator về một thiết kế tuân thủ Open/Closed Principle: class này phụ thuộc vào DiscountPolicy, còn mỗi loại khuyến mãi là một Spring bean riêng. Thêm khuyến mãi mới chỉ cần thêm implementation, không sửa phần điều phối đã ổn định. Nhưng khả năng mở rộng đó dựa trên một giả định chưa được kiểm tra: mọi DiscountPolicy phải hành xử theo cùng một contract.
Giả định này có thể hỏng ngay khi một implementation mới xuất hiện. Một policy chỉ chấp nhận khóa học có học phí từ 500.000 đồng dù interface không đặt giới hạn đó; policy khác trừ một khoản cố định rồi trả về học phí âm. Cả hai đều compile, đều được Spring phát hiện và inject bình thường. Lỗi chỉ xuất hiện lúc runtime, rất xa nơi abstraction được định nghĩa: request ghi danh hợp lệ phát sinh exception, hoặc database nhận một enrollment có học phí âm.
Open/Closed đã giúp ta thêm implementation mà không sửa client. Liskov Substitution Principle đặt ra điều kiện để khả năng đó an toàn: implementation mới phải thay thế được implementation cũ mà không làm thay đổi correctness của client. Nếu client phải biết concrete type để né một method, thêm instanceof, bổ sung try-catch riêng hoặc sửa lại cách diễn giải kết quả, abstraction đã mất giá trị.
Ghi chú
LSP thật sự nói gì
Ở dạng thực dụng, Liskov Substitution Principle nói rằng subtype phải thay thế được supertype. Robert C. Martin dùng cách phát biểu ngắn này để hướng dẫn thiết kế inheritance hierarchy. Nền tảng của nó đến từ định nghĩa subtype do Barbara Liskov nêu năm 1988 và được Barbara Liskov cùng Jeannette Wing phát triển thành behavioral subtyping: nếu một chương trình được viết theo type T, thay một object T bằng object của subtype S không được làm thay đổi behavior đúng đắn mà chương trình quan sát.
Điểm cần chú ý nằm ở từ behavior. Java có thể xác nhận một class implements DiscountPolicy, method có đúng tên và đúng kiểu trả về. Đó là nominal subtyping: quan hệ subtype được khai báo bằng tên type và được compiler kiểm tra. Compiler không thể biết apply phải trả học phí cuối cùng hay số tiền được giảm, không biết một input nào được xem là hợp lệ, cũng không biết gọi method có được phép gửi email hay sửa database. Những kỳ vọng đó thuộc behavioral contract và chỉ lộ ra khi client sử dụng object.
Vì vậy, LSP không chỉ áp dụng cho class B extends A. Nó áp dụng cho class implement Java interface, nhiều Spring bean cùng được inject dưới một type, nhiều repository adapter cùng đứng sau một port, và ở cấp kiến trúc còn áp dụng cho nhiều service cùng công bố một REST contract. Bất cứ nơi nào client được phép thay một implementation bằng implementation khác, substitutability đều là điều kiện correctness. Clean Architecture dùng chính cách nhìn này để mở rộng LSP từ inheritance ở cấp class lên interface và service boundary ở cấp kiến trúc.
Contract được nhìn từ phía client
Contract là tập hợp những cam kết giữa bên gọi và bên cài đặt một operation. Method signature chỉ là phần dễ thấy nhất. Contract đầy đủ còn bao gồm input nào được chấp nhận, output nào được bảo đảm, state thay đổi ra sao, side effect nào xảy ra và exception nào có thể được phát sinh. Một contract có thể được thể hiện bằng type, annotation, JavaDoc, automated test hoặc quy tắc nghiệp vụ; dù được viết ra hay chỉ tồn tại dưới dạng kỳ vọng hợp lý, client vẫn phụ thuộc vào nó.
Design by Contract cung cấp ba khái niệm để phân tích những cam kết đó. Precondition là điều kiện client phải bảo đảm trước khi gọi method. Postcondition là điều kiện implementation bảo đảm sau khi method hoàn tất. Invariant là điều kiện phải luôn đúng đối với state quan sát được của object giữa các operation. Trong domain LMS, DiscountPolicy.apply(baseFee) có thể đặt precondition rằng học phí đầu vào không âm; postcondition rằng kết quả là học phí phải trả, nằm từ 0 đến học phí gốc; và Enrollment giữ invariant rằng học phí đã ghi nhận không bao giờ âm.
Từ góc nhìn này, quy tắc LSP trở nên cụ thể. Subtype không được thắt chặt precondition, vì client của supertype không biết phải đáp ứng một yêu cầu mới. Subtype không được nới lỏng postcondition, vì client có quyền dựa vào lời hứa của supertype. Subtype cũng phải giữ invariant mà supertype công bố. Subtype được phép chấp nhận nhiều input hơn hoặc bảo đảm kết quả mạnh hơn, miễn mọi điều supertype đã hứa vẫn đúng.
Exception cũng thuộc contract. Nếu supertype nói một operation chấp nhận mọi học phí không âm và không công bố exception cho trường hợp này, subtype không thể tùy ý ném IllegalArgumentException với học phí 300.000 đồng. Ngược lại, nếu contract nói rõ một operation là optional và có thể ném UnsupportedOperationException, behavior đó không gây bất ngờ cho client. Điểm quyết định không phải sự hiện diện của exception, mà là exception có nằm trong contract mà client đã được phép dựa vào hay không.
Ghi chú
Case chính: `DiscountPolicy` phá vỡ phần điều phối đã đóng
Ta tiếp tục đúng extension point của Chương 4. Ở chương trước, chữ ký dùng double để giữ ví dụ OCP ngắn gọn; từ chương này, ví dụ chuyển sang BigDecimal để loại bỏ nhiễu do biểu diễn tiền tệ. Việc đổi kiểu dữ liệu không làm thay đổi vấn đề LSP đang xét. DiscountPolicy nhận học phí gốc và trả học phí cuối cùng sau khi áp dụng chính sách. FeeCalculator chọn policy theo mã và không biết concrete class nào đang thực thi. Đây là cấu trúc hợp lệ về OCP, nhưng hai implementation dưới đây vi phạm behavioral contract theo hai cách khác nhau.
public interface DiscountPolicy {
String code();
// Contract ngầm của Chương 4:
// - chấp nhận mọi baseFee >= 0
// - trả học phí cuối cùng trong khoảng [0, baseFee]
BigDecimal apply(BigDecimal baseFee);
}
@Component
public class MinimumSpendDiscount implements DiscountPolicy {
public String code() {
return "MIN500";
}
@Override
public BigDecimal apply(BigDecimal baseFee) {
if (baseFee.compareTo(new BigDecimal("500000")) < 0) {
// Vi phạm: subtype thêm precondition mà interface không có
throw new IllegalArgumentException("Học phí phải từ 500.000 VND");
}
return baseFee.multiply(new BigDecimal("0.80"));
}
}
@Component
public class FixedAmountDiscount implements DiscountPolicy {
public String code() {
return "FIXED600";
}
@Override
public BigDecimal apply(BigDecimal baseFee) {
// Vi phạm: khóa học 500.000 VND sẽ cho kết quả -100.000 VND
return baseFee.subtract(new BigDecimal("600000"));
}
}
MinimumSpendDiscount thắt chặt precondition. Từ góc nhìn DiscountPolicy, 300.000 đồng là input hợp lệ vì không âm; từ góc nhìn subtype, input đó bị từ chối. FixedAmountDiscount nhận input hợp lệ nhưng nới lỏng postcondition bằng cách trả kết quả âm, đồng thời đẩy Enrollment vào trạng thái phá invariant. Hai lỗi đều không thể hiện trên method signature.
Phản ứng thường gặp là phòng thủ trong client. Lập trình viên thêm điều kiện nhận diện MinimumSpendDiscount, bắt exception riêng, hoặc ép kết quả âm về 0 trong FeeCalculator. Cách sửa này làm client biết chi tiết của subtype và phải đổi mỗi khi xuất hiện một implementation lệch contract mới. Đây là điều Martin gọi là một vi phạm LSP tiềm ẩn kéo theo vi phạm OCP: phần điều phối không còn đóng vì phải chứa special case cho từng subtype.
@Component
public class FeeCalculator {
private final Map<String, DiscountPolicy> policies;
public FeeCalculator(List<DiscountPolicy> policyList) {
this.policies = policyList.stream()
.collect(Collectors.toMap(DiscountPolicy::code, Function.identity()));
}
public BigDecimal calculate(BigDecimal baseFee, String promoCode) {
DiscountPolicy policy = policies.get(promoCode);
if (policy == null) {
return baseFee;
}
// Cách phòng thủ sai hướng: client phải biết concrete subtype
if (policy instanceof MinimumSpendDiscount
&& baseFee.compareTo(new BigDecimal("500000")) < 0) {
return baseFee;
}
return policy.apply(baseFee).max(BigDecimal.ZERO);
}
}
Đoạn instanceof không chỉ làm FeeCalculator khó đọc hơn. Nó chuyển trách nhiệm giữ contract từ implementation sang client, khiến mọi client khác của DiscountPolicy phải lặp lại cùng logic phòng thủ. Nếu một batch job tính lại học phí không có đoạn clamp, cùng policy sẽ tạo kết quả khác giữa hai luồng. Một abstraction chỉ đáng tin khi client không cần cơ chế bảo vệ theo concrete type.
Làm contract rõ ràng và giữ mọi implementation substitutable
Bản sửa bắt đầu ở abstraction, không bắt đầu bằng việc thêm nhánh trong client. Ta biểu diễn học phí bằng một Value Object Fee để trạng thái âm không thể được tạo, ghi contract ngay tại interface và sửa từng implementation để chấp nhận toàn bộ miền input hợp lệ. Việc chuyển từ double sang BigDecimal cũng phù hợp với dữ liệu tiền tệ, nhưng mục tiêu chính ở đây là làm invariant trở thành một phần của model thay vì một quy ước rải rác.
public record Fee(BigDecimal amount) {
public Fee {
Objects.requireNonNull(amount, "amount");
if (amount.signum() < 0) {
throw new IllegalArgumentException("Học phí không được âm");
}
amount = amount.stripTrailingZeros();
}
public static Fee of(String amount) {
return new Fee(new BigDecimal(amount));
}
public Fee multiply(String factor) {
return new Fee(amount.multiply(new BigDecimal(factor)));
}
public Fee subtractAtMost(Fee reduction) {
return new Fee(amount.subtract(reduction.amount()).max(BigDecimal.ZERO));
}
}
public interface DiscountPolicy {
String code();
/**
* Trả học phí cuối cùng sau khi áp dụng policy.
* Mọi Fee hợp lệ đều được chấp nhận.
* Không ném exception đối với một Fee hợp lệ.
* Kết quả luôn nằm trong khoảng từ 0 đến baseFee.
*/
Fee apply(Fee baseFee);
}
Fee bảo vệ invariant tại một nơi. Tuy nhiên, Value Object không thể tự bảo đảm rằng policy không trả học phí cao hơn giá gốc; đó vẫn là postcondition của DiscountPolicy. Vì vậy mỗi implementation phải giữ cả semantics của operation, không chỉ tạo được một object hợp lệ.
@Component
public class MinimumSpendDiscount implements DiscountPolicy {
private static final Fee MINIMUM = Fee.of("500000");
public String code() {
return "MIN500";
}
@Override
public Fee apply(Fee baseFee) {
// Input dưới ngưỡng vẫn hợp lệ; policy đơn giản là không giảm
if (baseFee.amount().compareTo(MINIMUM.amount()) < 0) {
return baseFee;
}
return baseFee.multiply("0.80");
}
}
@Component
public class FixedAmountDiscount implements DiscountPolicy {
private static final Fee REDUCTION = Fee.of("600000");
public String code() {
return "FIXED600";
}
@Override
public Fee apply(Fee baseFee) {
// Không thể giảm vượt quá học phí gốc
return baseFee.subtractAtMost(REDUCTION);
}
}
@Component
public class FeeCalculator {
private final Map<String, DiscountPolicy> policies;
public FeeCalculator(List<DiscountPolicy> policyList) {
this.policies = policyList.stream()
.collect(Collectors.toUnmodifiableMap(
DiscountPolicy::code,
Function.identity()
));
}
public Fee calculate(Fee baseFee, String promoCode) {
DiscountPolicy policy = policies.get(promoCode);
return policy == null ? baseFee : policy.apply(baseFee);
}
}
Sau refactor, FeeCalculator trở lại đúng vai trò client của abstraction: chọn policy rồi gọi apply. Nó không kiểm tra concrete type, không bắt exception riêng cho một implementation và không sửa kết quả sau khi nhận. Policy dưới ngưỡng trả nguyên học phí, vì “không được giảm” là một kết quả hợp lệ của discount policy; policy giảm cố định không bao giờ vượt quá số tiền phải trả. Cả hai đều giữ contract với mọi Fee hợp lệ.
Đánh đổi của bản thiết kế này là có thêm Value Object và một contract phải được duy trì. Với một phép tính dùng một lần trong prototype, chi phí đó có thể chưa cần thiết. Với extension point đã có nhiều Spring bean, tác động trực tiếp đến dữ liệu tài chính và được nhiều luồng sử dụng, chi phí này hợp lý: một invariant được bảo vệ tập trung rẻ hơn việc mọi client tự phòng thủ.
Contract test: kiểm tra cùng một lời hứa trên mọi implementation
JavaDoc làm contract rõ hơn cho con người, nhưng automated test mới cho phản hồi lặp lại khi implementation thay đổi. Contract test là một tập test dùng chung được chạy với mọi implementation của một abstraction. Nó không kiểm tra công thức giảm cụ thể của từng policy; nó kiểm tra những điều mọi policy đều phải bảo đảm, chẳng hạn không từ chối input hợp lệ và không trả học phí ngoài khoảng đã hứa.
@SpringBootTest
class DiscountPolicyContractTest {
@Autowired
private List<DiscountPolicy> policies;
private static final List<Fee> VALID_BASE_FEES = List.of(
Fee.of("0"),
Fee.of("300000"),
Fee.of("500000"),
Fee.of("1000000")
);
@Test
void every_policy_has_a_unique_non_blank_code() {
Set<String> codes = new HashSet<>();
for (DiscountPolicy policy : policies) {
assertFalse(policy.code().isBlank(), "Mã policy không được để trống");
assertTrue(
codes.add(policy.code()),
() -> "Mã policy bị trùng: " + policy.code()
);
}
}
@TestFactory
Stream<DynamicTest> every_policy_preserves_the_contract() {
return policies.stream().flatMap(policy ->
VALID_BASE_FEES.stream().map(baseFee ->
dynamicTest(policy.code() + " / " + baseFee.amount(), () -> {
Fee result = assertDoesNotThrow(() -> policy.apply(baseFee));
assertAll(
() -> assertNotNull(result),
() -> assertTrue(result.amount().signum() >= 0),
() -> assertTrue(
result.amount().compareTo(baseFee.amount()) <= 0,
"Policy không được làm học phí tăng"
)
);
})
)
);
}
}
Test này tận dụng đúng cơ chế của Spring: inject toàn bộ bean DiscountPolicy, nên một implementation mới tự động tham gia contract suite. Nếu policy mới thắt chặt input hoặc phá postcondition, build thất bại trước khi lỗi đi vào production. Mỗi policy vẫn cần test riêng cho business rule của nó, ví dụ MinimumSpendDiscount phải giảm đúng 20% khi đủ ngưỡng; contract test chỉ bảo vệ phần behavior chung mà client dựa vào. Chẳng hạn, một implementation trả về số tiền được giảm thay vì học phí cuối cùng vẫn có thể lọt qua các assertion về khoảng giá trị trong một số bộ dữ liệu; chỉ test đặc thù cho semantics của policy mới bắt được sự nhầm lẫn ấy.
Contract test cũng làm rõ một giới hạn quan trọng. Không có test hữu hạn nào chứng minh mọi property cho mọi input. Chất lượng của suite phụ thuộc vào việc team đã xác định đúng contract và chọn đủ trường hợp biên. Nó là executable specification thực dụng, không thay thế việc mô hình hóa domain cẩn thận.
Jakarta Validation và Spring làm LSP hiện ra ở runtime
Quy tắc không thắt chặt precondition không chỉ tồn tại trong sách thiết kế. Jakarta Bean Validation đưa nó thẳng vào đặc tả method validation. Với method trong inheritance hierarchy, subtype không được khai báo thêm parameter constraint trên method override hoặc method implement, vì client viết theo supertype không biết về yêu cầu mới đó. Provider tuân thủ đặc tả phải phát sinh ConstraintDeclarationException khi phát hiện hierarchy không hợp lệ.
public interface PromotionEligibility {
// Precondition được công bố tại supertype
boolean isEligible(@PositiveOrZero BigDecimal baseFee);
}
@Component
@Validated
public class PremiumPromotionEligibility implements PromotionEligibility {
@Override
public boolean isEligible(
@DecimalMin("500000") BigDecimal baseFee) { // không hợp lệ
return true;
}
}
Đoạn code trên không hợp lệ theo Jakarta Validation, dù constraint mới có vẻ hợp lý với business rule của implementation. Client chỉ biết @PositiveOrZero, nên 300.000 là input hợp lệ. Nếu subtype đòi từ 500.000, cùng một lời gọi sẽ thành hợp lệ hoặc không hợp lệ tùy object runtime. Cách sửa là giữ precondition ở supertype và để implementation trả false khi không đủ điều kiện, hoặc thiết kế một abstraction khác nếu operation thật sự có miền input khác.
Với postcondition, chiều cho phép ngược lại: subtype có thể thêm return-value constraint để bảo đảm nhiều hơn, nhưng không được loại bỏ lời hứa của supertype. Jakarta Validation tổng hợp các return constraint trong hierarchy, nên implementation không thể làm biến mất constraint đã công bố ở interface chỉ bằng cách bỏ annotation. Đây là một ví dụ hiếm khi framework thực thi trực tiếp một phần của behavioral subtyping.
Spring Framework tích hợp Jakarta Validation và cung cấp Spring-driven method validation. Trong ứng dụng Spring Boot, method validation được auto-configure khi có Bean Validation provider trên classpath; service cần @Validated để trở thành target của method-validation interceptor. Với Spring MVC hiện đại, validation cho controller method còn có cơ chế tích hợp riêng. Phần cấu hình cụ thể thay đổi theo boundary, nhưng nguyên tắc không đổi: constraint trên parameter và return value là một phần của public contract, không phải chi tiết tùy ý của từng implementation.
Khi inheritance mô tả sai behavior
Vi phạm LSP thường bắt đầu khi inheritance được chọn để tái sử dụng code, trong khi subtype không thật sự cung cấp toàn bộ behavior của supertype. Xét một hierarchy khác trong LMS. Course công bố operation lên lịch buổi học trực tiếp; LiveCourse thực hiện được, còn SelfPacedCourse không có khái niệm buổi học trực tiếp nên chỉ có thể ném exception.
public abstract class Course {
private final Long id;
private final String title;
protected Course(Long id, String title) {
this.id = id;
this.title = title;
}
public abstract Session scheduleLiveSession(Instant startsAt);
}
public class LiveCourse extends Course {
public LiveCourse(Long id, String title) {
super(id, title);
}
@Override
public Session scheduleLiveSession(Instant startsAt) {
return new Session(startsAt);
}
}
public class SelfPacedCourse extends Course {
public SelfPacedCourse(Long id, String title) {
super(id, title);
}
@Override
public Session scheduleLiveSession(Instant startsAt) {
throw new UnsupportedOperationException(
"Khóa self-paced không có live session"
);
}
}
SelfPacedCourse là course theo ngôn ngữ đời thường, nhưng không phải behavioral subtype của abstraction Course đang được code định nghĩa. Một client nhận Course có quyền gọi scheduleLiveSession; subtype làm operation đó thất bại ngoài contract. Comment “không gọi method này với self-paced course” không sửa được hierarchy, vì nó chỉ chuyển một ràng buộc ẩn sang trí nhớ của mọi lập trình viên.
Giải pháp không nhất thiết là tìm một override tinh vi hơn. Ta cần sửa abstraction: giữ ở Course những behavior thật sự chung, còn trục delivery được xây bằng composition. Mọi CourseDelivery cùng thực hiện một operation có semantics chung là mở quyền truy cập học tập; scheduling chỉ tồn tại trong collaborator của live delivery, nơi capability đó có ý nghĩa.
public final class Course {
private final Long id;
private final String title;
private final CourseDelivery delivery;
public Course(Long id, String title, CourseDelivery delivery) {
this.id = id;
this.title = title;
this.delivery = delivery;
}
public AccessGrant openAccessFor(Enrollment enrollment) {
return delivery.openAccessFor(enrollment);
}
}
public interface CourseDelivery {
// Mọi delivery đều phải cấp một AccessGrant hợp lệ
AccessGrant openAccessFor(Enrollment enrollment);
}
public final class LiveCourseDelivery implements CourseDelivery {
private final LiveSessionScheduler sessionScheduler;
public LiveCourseDelivery(LiveSessionScheduler sessionScheduler) {
this.sessionScheduler = sessionScheduler;
}
@Override
public AccessGrant openAccessFor(Enrollment enrollment) {
Session nextSession = sessionScheduler.nextSession(enrollment.courseId());
return AccessGrant.forScheduledSession(nextSession);
}
}
public final class SelfPacedCourseDelivery implements CourseDelivery {
@Override
public AccessGrant openAccessFor(Enrollment enrollment) {
return AccessGrant.immediate(enrollment.courseId());
}
}
Thiết kế mới không ép self-paced delivery nhận operation scheduling. Course compose một CourseDelivery và ủy quyền operation chung; live delivery tự dùng LiveSessionScheduler, self-paced delivery cấp quyền ngay. Client của Course chỉ dựa vào contract “mở quyền truy cập” và nhận một AccessGrant hợp lệ ở cả hai trường hợp. Đây là Replace Inheritance with Delegation ở dạng phù hợp với domain: tách behavior biến thiên khỏi entity thay vì tạo một hierarchy mà subtype phải từ chối một phần interface.
Composition không phải lựa chọn luôn thắng. Fowler lưu ý inheritance vẫn là cơ chế có giá trị khi quan hệ behavior ổn định và subtype thật sự giữ toàn bộ contract; delegation linh hoạt hơn nhưng đổi lại có thêm object và đường ủy quyền. Quy tắc thực dụng là không dùng inheritance chỉ vì muốn tái sử dụng vài field hoặc method. Khi subclass bắt đầu từ chối interface của superclass, cần xem lại hierarchy hoặc chuyển sang delegate.
LSP xuất hiện trong hệ thống thật như thế nào
Một case thực tế trong Agile Principles, Patterns, and Practices cho thấy vi phạm LSP khó phát hiện hơn ví dụ sách giáo khoa. Martin từng bọc nhiều container của thư viện bên thứ ba sau abstraction Set. Khi thêm PersistentSet, implementation mới chỉ chấp nhận phần tử kế thừa PersistentObject, trong khi Set.add không công bố hạn chế đó. PersistentSet đã thắt chặt precondition; một client hợp lệ với mọi Set cũ có thể phát sinh lỗi runtime khi nhận implementation mới.
Giải pháp đầu tiên dựa vào convention: chỉ một module được biết và sử dụng PersistentSet. Cách này không bền vững; các developer không biết hoặc không đồng ý với convention đã dùng type ở nơi khác, làm ràng buộc tiếp tục bị vi phạm. Thiết kế sau đó factor behavior thực sự chung — membership và iteration — vào một abstraction chung, còn operation thêm phần tử nằm ở contract phù hợp với từng loại set. Kinh nghiệm đáng chú ý không phải tên class, mà là giới hạn của quy ước truyền miệng: nếu correctness phụ thuộc vào việc “nhớ không dùng subtype ở đây”, type hierarchy chưa biểu diễn đúng contract.
Ở cấp kiến trúc, Clean Architecture mô tả một hệ thống điều phối nhiều hãng taxi qua cùng REST interface. Chỉ một provider dùng tên field khác cho destination khiến phía client phải nhận diện provider đó và dựng request theo format riêng. Một sai lệch nhỏ ở service contract tạo thêm special case, configuration và rủi ro trên toàn kiến trúc phía gọi. LSP vì thế không dừng ở class: các adapter và external service được hoán đổi dưới cùng một port cũng phải giữ semantics chung.
Kinh nghiệm trong solidbook đưa ra một trường hợp quen thuộc với Spring: repository interface cho phép đổi giữa in-memory implementation khi test, MySQL ở hiện tại và MongoDB về sau. Khả năng thay thế này chỉ có thật nếu các implementation thống nhất cả behavior, không chỉ method signature. findById phải có cùng not-found semantics; save phải có cùng cam kết về identity và state; exception hạ tầng không nên rò ra theo ba kiểu khác nhau. Nếu test dùng in-memory repository pass nhưng production adapter diễn giải contract khác, abstraction đã che giấu bất tương thích thay vì cô lập công nghệ.
`UnsupportedOperationException`: dấu hiệu, không phải phán quyết
Override một method rồi ném UnsupportedOperationException là một code smell mạnh, vì nó thường cho thấy subtype nhận một operation không thể thực hiện. SelfPacedCourse là trường hợp đó. Tuy nhiên, kết luận “mọi UnsupportedOperationException đều vi phạm LSP” lại bỏ qua chính điều LSP yêu cầu: phải đọc contract.
Java Collections Framework chủ ý định nghĩa một số mutator là optional operation. Contract của Collection.add, remove hoặc clear công bố rằng implementation không hỗ trợ operation có thể ném UnsupportedOperationException. Unmodifiable collection vẫn substitutable theo contract đó, vì client đã được thông báo khả năng operation không được hỗ trợ; một client cần mutation vô điều kiện phải chọn abstraction hoặc precondition phù hợp hơn.
Thiết kế optional operation có đánh đổi và từng gây tranh luận trong API design, nhưng nó minh họa một bài học quan trọng: exception dự kiến trong contract khác với exception do subtype tự thêm. Trong code LMS, nếu Course.scheduleLiveSession hứa operation khả dụng cho mọi course, exception của SelfPacedCourse là bất ngờ và vi phạm. Nếu interface công bố rõ operation optional, client không thể giả định nó luôn thành công. Khi review, hãy dùng exception như tín hiệu mở contract ra kiểm tra, không dùng nó như một luật tìm kiếm-thay thế máy móc.
Dấu hiệu nhận biết vi phạm LSP khi review code
LSP violation thường không nằm trong một dòng riêng lẻ. Nó xuất hiện qua mối quan hệ giữa supertype, subtype và client. Những dấu hiệu sau giúp khoanh vùng nơi cần đọc contract kỹ hơn.
Client có
instanceof, switch theo class/name hoặc nhánh riêng cho một implementation.Override ném exception mới, để method rỗng, trả
nullhoặc sentinel trong khi supertype hứa một kết quả hợp lệ.Subtype từ chối input mà supertype chấp nhận, chẳng hạn thêm ngưỡng, format hoặc trạng thái bắt buộc không được công bố.
Subtype thay đổi semantics của output: trả số tiền giảm trong khi interface hứa học phí phải trả; trả pending nhưng client hiểu là completed.
Subtype thay đổi side effect hoặc state transition mà client dựa vào, chẳng hạn
savekhông thật sự làm dữ liệu có thể đọc lại.Comment hoặc tài liệu nội bộ chứa chỉ dẫn “không gọi method X nếu object là Y”.
Test viết theo supertype pass với implementation A nhưng cùng assertion thất bại với implementation B.
Subclass giữ nhiều field thừa hoặc override nhiều method chỉ để vô hiệu hóa behavior kế thừa — code smell Refused Bequest.
Không dấu hiệu nào tự nó là bằng chứng tuyệt đối. Một instanceof có thể hợp lệ ở boundary chuyển đổi type; null có thể nằm trong contract; exception có thể được công bố. Bước tiếp theo luôn là hỏi: client có quyền kỳ vọng điều gì từ supertype, và implementation này có giữ kỳ vọng đó cho toàn bộ miền input hợp lệ không?
Khi nào đầu tư vào LSP — và khi nào không tạo hierarchy
LSP chỉ phát sinh khi có quan hệ thay thế: inheritance, interface với nhiều implementation, nhiều adapter sau một port hoặc nhiều service cùng contract. Vì vậy câu “không áp dụng LSP” cần được hiểu chính xác là không tạo quan hệ subtype khi bài toán không cần substitution, chứ không phải chấp nhận subtype sai contract. Nếu một prototype chỉ có vài class concrete và không dùng polymorphism, không cần tạo supertype giả để chứng minh đã áp dụng LSP. Một hierarchy được tạo khi chưa có nhu cầu thay thế chỉ thêm abstraction và có thể dẫn tới Speculative Generality đã bàn ở Chương 4.
Khi supertype đã được công bố và client thực sự dùng polymorphism, LSP không còn là lựa chọn thẩm mỹ. Nó là điều kiện correctness. Vùng cần đầu tư mạnh nhất là public boundary sống lâu, extension point có nhiều implementation, payment/pricing, persistence port và code do nhiều team cùng mở rộng. Ở những nơi đó, JavaDoc rõ, Value Object, validation và contract test thường hoàn vốn nhanh bằng việc phát hiện bất tương thích trước production.
Chẳng hạn, EnrollmentRepository có in-memory adapter cho test và PostgreSQL adapter ở production thì cần một contract test chung về not-found semantics, identity sau save và khả năng đọc lại dữ liệu. Client thật sự hoán đổi hai implementation, nên khác biệt hành vi là rủi ro production. Trái lại, một migration một lần chỉ có LegacyCsvEnrollmentImporter, được gọi trực tiếp rồi xóa sau đợt chuyển dữ liệu, không cần tạo EnrollmentImporter cùng hierarchy giả định cho JSON hoặc Excel chưa có yêu cầu. Ở trường hợp thứ hai, test concrete importer là đủ; thêm interface không làm hệ thống đúng hơn.
Điều không nên làm là formalize mọi private helper bằng một bộ precondition/postcondition dài hoặc xây inheritance hierarchy chỉ để dùng lại code. Private code đơn giản, ổn định có thể dựa vào test cục bộ và type system. Nếu hai class chỉ dùng chung một thuật toán nhưng không cùng behavioral contract, hãy extract collaborator và compose nó. Nếu subtype không thể giữ lời hứa của supertype, đừng ép subtype; đổi abstraction thường rẻ hơn việc bắt mọi client chứa special case.
Ví dụ, RecordedCourse và LiveCourse đều cần tính thời lượng, nhưng chỉ LiveCourse có thể lập lịch phòng học trực tuyến. Cho RecordedCourse extends LiveCourse để dùng lại phép tính rồi override scheduleLiveSession() bằng exception là áp dụng inheritance sai chỗ. Một DurationCalculator dùng qua composition giữ được phần reuse mà không tạo lời hứa thay thế giả. Ngược lại, PercentageDiscount và FixedAmountDiscount đều nhận mọi Fee hợp lệ và trả học phí cuối cùng trong cùng miền giá trị; chúng là các subtype có ý nghĩa và đáng được bảo vệ bằng contract test.
LSP bản thân không bắt buộc runtime validation ở mọi lời gọi. Trên đường code nhạy hiệu năng, contract có thể được bảo vệ bằng type, construction invariant và test thay vì interceptor hoặc assertion runtime. Đây là đánh đổi kỹ thuật cần đo đạc. Nguyên lý yêu cầu behavior thay thế an toàn, không yêu cầu một cơ chế thực thi duy nhất.
Mức đầu tư vì thế phải tương xứng với phạm vi ảnh hưởng. Một private strategy có hai implementation trong cùng module có thể chỉ cần interface nhỏ và unit test dùng chung. Một Spring extension point được nhiều team bổ sung cần JavaDoc, Value Object và contract suite tự động quét bean. Một public library còn cần chính sách tương thích ngược và test cho implementation của bên thứ ba. Ta linh hoạt ở mức bảo vệ và cơ chế kiểm chứng; không linh hoạt ở lời hứa hành vi mà client đã được quyền dựa vào.
Câu hỏi quyết định riêng cho LSP là: “Client viết hoàn toàn theo supertype có còn đúng khi nhận implementation này, mà không cần biết implementation cụ thể không?”. Nếu câu trả lời là không, có ba hướng: sửa implementation để giữ contract, thu hẹp/đổi contract, hoặc bỏ quan hệ subtype và chuyển sang composition.
Một review thực dụng có thể đi theo bốn bước: xác nhận có nhu cầu substitution thật; viết ra behavior chung mà client quan sát; thử cùng assertion trên mọi implementation; cuối cùng so chi phí duy trì hierarchy với phương án concrete class hoặc composition. Nếu chưa có substitution, giữ thiết kế concrete. Nếu có substitution và contract ổn định, đầu tư test theo mức rủi ro. Nếu mỗi implementation cần một nhánh giải thích riêng, đó không phải lý do “nới LSP”, mà là bằng chứng abstraction đang đặt sai ranh giới.
LSP trong mối quan hệ với các nguyên lý khác
LSP là điều kiện để OCP hoạt động. Chương 4 cho phép thêm implementation mà không sửa client; Chương 5 bảo đảm implementation mới không buộc client sửa vì behavior bất thường. Một extension point có nhiều subtype không substitutable chỉ “mở” trên sơ đồ class, còn trong runtime vẫn tạo rigidity và fragility qua các special case.
Vi phạm LSP cũng thường báo hiệu interface quá rộng. SelfPacedCourse phải từ chối scheduling vì supertype đã công bố một operation không thuộc mọi subtype. Chương 6 sẽ đi tiếp bằng Interface Segregation Principle: thiết kế interface theo nhu cầu client và tách capability để implementation không bị ép nhận method vô nghĩa. Composition ở ví dụ CourseDelivery đã cho thấy hướng đi, nhưng tiêu chí chia interface sẽ được phân tích riêng ở chương sau.
Dependency Inversion ở Chương 7 cũng cần LSP. Phụ thuộc vào abstraction chỉ tạo loose coupling khi các implementation của abstraction thật sự hoán đổi được. Nếu mỗi adapter có semantics riêng buộc high-level module hiểu concrete detail, mũi tên dependency đã đảo nhưng coupling hành vi vẫn còn.
Những hiểu lầm phổ biến
“Compile được và override đúng signature là đạt LSP.” Không. Compiler kiểm tra quan hệ type; LSP kiểm tra behavior mà client quan sát.
“LSP chỉ áp dụng cho
extends.” Không. Java interface, Spring bean, repository adapter và REST provider đều có thể tham gia quan hệ thay thế.“LSP chỉ yêu cầu subtype không ném exception.” Không. Input, output, state transition, side effect và exception đều thuộc contract.
“Mọi
UnsupportedOperationExceptionđều là vi phạm.” Không. Nếu optional operation được công bố trong contract như Java Collections, exception là behavior dự kiến.“Quan hệ IS-A ngoài đời đủ để chọn inheritance.” Không. Software subtype được quyết định bởi behavioral substitutability trong ngữ cảnh client.
“Composition luôn tốt hơn inheritance.” Không. Composition linh hoạt và giảm coupling hierarchy, nhưng thêm delegation; inheritance vẫn phù hợp khi behavior chung ổn định và mọi subtype giữ contract.
“Thêm validation trong subtype làm code an toàn hơn.” Không nhất thiết. Parameter constraint chặt hơn có thể phá client của supertype và vi phạm cả quy tắc method validation của Jakarta Validation.
Chương này nối với phần còn lại của sách ra sao
Chương 4 xây extension point bằng abstraction và polymorphism; Chương 5 đặt ra contract để extension point đó đáng tin. Từ đây, Chương 6 giải quyết một nguyên nhân phổ biến của LSP violation: interface quá lớn ép implementation nhận operation nó không hỗ trợ. Chương 7 tiếp tục bằng Dependency Inversion, nơi high-level module chỉ có thể phụ thuộc an toàn vào abstraction nếu mọi low-level implementation giữ behavioral contract. Khi review một hierarchy, đừng dừng ở câu “class này có implement interface không”; hãy kiểm tra client đang dựa vào lời hứa nào và mọi implementation có giữ lời hứa đó không.
Tóm tắt
LSP yêu cầu subtype thay thế được supertype mà không làm thay đổi correctness của client; cùng signature chưa đủ, behavior mới là tiêu chí quyết định.
Contract gồm precondition, postcondition, invariant, state transition, side effect và exception mà client có thể quan sát.
Subtype không được thắt chặt precondition, không được nới lỏng postcondition và phải giữ invariant của supertype.
Vi phạm LSP thường làm client xuất hiện special case theo concrete type, từ đó phá OCP.
Value Object, contract rõ và contract test dùng chung giúp nhiều Spring bean giữ cùng behavior; Jakarta Validation còn thực thi trực tiếp quy tắc method constraint trong hierarchy.
Override ném
UnsupportedOperationExceptionlà code smell, nhưng phải đọc contract: optional operation được công bố rõ có thể hợp lệ.Khi inheritance chỉ phục vụ reuse hoặc subtype phải từ chối một phần interface, hãy sửa abstraction hoặc chuyển sang composition/delegation.
LSP chỉ phát sinh khi có substitution; khi đã công bố subtype, đây là ràng buộc correctness chứ không phải trang trí thiết kế.
Linh hoạt nằm ở quyết định có tạo subtype hay không và mức kiểm chứng cần dùng: code một lần có thể giữ concrete, còn public/multi-implementation boundary cần contract rõ và contract test tương xứng rủi ro.
Bài tập cuối chương
Khi làm quiz, contract analysis, code-spotting và bài refactor cuối chương, hãy phân biệt quyết định không tạo hierarchy với việc cho phép một subtype đã công bố vi phạm behavioral contract.