Open/Closed Principle (OCP)
Cuối Chương 3, ta đã tách FeeCalculator ra khỏi EnrollmentService để nó chỉ phục vụ một actor là đội tài chính. Nhưng chính ở đó, một câu đã được hứa để ngỏ: bên trong FeeCalculator vẫn là một chuỗi if-else xử lý từng mã khuyến mãi, và thêm một loại khuyến mãi mới vẫn buộc phải mở class đó ra sửa. Single Responsibility đã đặt logic khuyến mãi đúng chỗ; giờ Open/Closed Principle trả lời câu hỏi tiếp theo: làm sao thêm một loại khuyến mãi mà không phải sửa lại đoạn code đã chạy đúng.
OCP là nguyên lý gắn liền nhất với khả năng mở rộng của hệ thống. Nếu SRP quyết định đặt code ở đâu, thì OCP quyết định thêm hành vi mới bằng cách nào. Đây cũng là nguyên lý mà Robert C. Martin nói ông thích nhất, vì nó đòi hỏi ta dựng codebase sao cho việc thêm tính năng chủ yếu là thêm code mới thay vì sửa code cũ. Chương này trình bày OCP theo đúng nguồn gốc của nó, áp dụng bằng Strategy pattern trong Spring, và — quan trọng không kém — chỉ ra khi nào mở điểm mở rộng là đúng lúc, khi nào đó chỉ là trừu tượng hóa thừa.
Lời hứa từ Chương 3 và cái giá của việc sửa code cũ
Yêu cầu đến đúng như dự đoán: đội tài chính muốn thêm mã khuyến mãi BLACKFRIDAY giảm 30% cho mùa lễ. Với thiết kế hiện tại, lập trình viên phải mở FeeCalculator và nối thêm một nhánh vào chuỗi if-else. Việc này nghe nhỏ, nhưng nó chạm vào một method đang chứa mọi quy tắc tính phí khác; một sơ suất ở đây có thể làm sai các mã khuyến mãi cũ vốn đang chạy đúng. Đây chính là hai dấu hiệu thiết kế kém đã học ở Chương 1: rigidity (mỗi lần thêm loại mới lại phải sửa cùng một chỗ) và fragility (sửa chỗ này có nguy cơ làm hỏng chỗ khác).
Ghi chú
OCP thật sự nói gì
Open/Closed Principle được Bertrand Meyer đặt ra từ năm 1988, và được phát biểu ngắn gọn: một software entity (class, module, function) nên mở để mở rộng, nhưng đóng với sửa đổi. Hai vế nghe như mâu thuẫn, nên cần hiểu chính xác từng vế.
"Mở để mở rộng" (open for extension) nghĩa là hành vi của module có thể được bổ sung khi yêu cầu thay đổi — ta có thể làm cho nó làm thêm việc mới. "Đóng với sửa đổi" (closed for modification) nghĩa là việc bổ sung hành vi đó không đòi hỏi sửa mã nguồn của module đã có. Nói cách khác: thêm một biến thể mới trên một trục thay đổi không buộc bạn chỉnh lại đoạn code lõi đã được kiểm thử.
Làm sao đạt được điều tưởng chừng nghịch lý đó? Chìa khóa là abstraction. Một module có thể phụ thuộc vào một abstraction cố định (một interface), nhờ vậy nó "đóng" — không cần sửa; nhưng hành vi của hệ thống vẫn "mở" — vì ta có thể tạo thêm các implementation mới của abstraction đó. Module lõi thao tác trên interface; mỗi biến thể mới là một class mới implement interface. Đây là lý do OCP luôn đi cùng polymorphism.
Cần gỡ một hiểu lầm phổ biến: OCP không có nghĩa là "không bao giờ được sửa code". Code vẫn được sửa để vá lỗi, để cải thiện, hay khi chính yêu cầu lõi thay đổi. OCP chỉ nhắm vào một kiểu thay đổi cụ thể — thêm biến thể mới trên một trục đã biết — và nói rằng kiểu thay đổi đó nên thực hiện bằng cách thêm code, không phải sửa code cũ. Cũng nên biết OCP có hai cách hiểu trong lịch sử: cách của Meyer nhấn mạnh mở rộng qua inheritance, còn cách phổ biến ngày nay (thường gắn với Martin) nhấn mạnh phụ thuộc vào abstract interface và thêm implementation. Cuốn sách này đi theo hướng thứ hai, vì nó hợp với cách Spring khuyến khích thiết kế.
Vì sao OCP đáng giá trong code doanh nghiệp
Giá trị của OCP đo được bằng chi phí và rủi ro của mỗi lần thêm tính năng. Khi thêm một biến thể chỉ là thêm một class mới, đoạn code lõi đã kiểm thử không bị ảnh hưởng, nên nguy cơ hồi quy (regression) gần như bằng không: bạn không thể làm hỏng thứ mà bạn không sửa. Việc kiểm thử cũng gọn hơn — chỉ cần test class mới, không phải test lại toàn bộ luồng cũ.
Ở góc độ tổ chức, OCP còn cho phép mở rộng song song và an toàn: nhiều người có thể thêm các biến thể khác nhau mà không chạm vào cùng một file, giảm merge conflict — nối tiếp lợi ích đã thấy ở Chương 3. Và với những hệ thống cần bên thứ ba cắm thêm tính năng (plugin), OCP là điều kiện tiên quyết: phần lõi đóng gói sẵn, người khác mở rộng qua abstraction mà không cần mã nguồn của lõi.
Áp dụng OCP: Strategy pattern trong Spring
Kỹ thuật kinh điển để đạt OCP là thay chuỗi điều kiện theo type bằng đa hình — Martin Fowler gọi đây là refactoring "Replace Conditional with Polymorphism", và nó nhắm vào code smell "Switch Statements" (chuỗi if-else/switch rẽ nhánh theo loại). Hai pattern hay dùng nhất để hiện thực OCP là Strategy và Template Method; ở đây ta dùng Strategy vì nó hợp với cơ chế bean của Spring.
Đây là FeeCalculator ở trạng thái vi phạm OCP — chuỗi if-else mà mỗi loại khuyến mãi mới buộc phải sửa.
@Component
public class FeeCalculator {
private final CourseRepository courseRepository;
public FeeCalculator(CourseRepository courseRepository) {
this.courseRepository = courseRepository;
}
public double calculate(Long courseId, String promoCode) {
if (courseRepository.isInternalCourse(courseId)) {
return 0;
}
double fee = courseRepository.loadBaseFee(courseId);
// Mỗi loại khuyến mãi mới đều buộc phải MỞ và SỬA method này
if ("WELCOME10".equals(promoCode)) {
return fee * 0.9;
} else if ("VIP50".equals(promoCode)) {
return fee * 0.5;
}
return fee;
}
}
Áp dụng Strategy, ta đưa mỗi loại khuyến mãi thành một implementation của một abstraction chung là DiscountPolicy. FeeCalculator chỉ còn phụ thuộc vào abstraction đó; Spring inject tất cả các bean DiscountPolicy vào và ta gom chúng thành một registry theo mã.
// Abstraction cố định — FeeCalculator sẽ "đóng" quanh interface này
public interface DiscountPolicy {
String code(); // mã khuyến mãi mà policy này phụ trách
double apply(double baseFee); // trả về học phí sau khi giảm
}
@Component
public class WelcomeDiscount implements DiscountPolicy {
public String code() { return "WELCOME10"; }
public double apply(double baseFee) { return baseFee * 0.9; }
}
@Component
public class VipDiscount implements DiscountPolicy {
public String code() { return "VIP50"; }
public double apply(double baseFee) { return baseFee * 0.5; }
}
@Component
public class FeeCalculator {
private final CourseRepository courseRepository;
private final Map<String, DiscountPolicy> policiesByCode;
// Spring inject TẤT CẢ bean DiscountPolicy; gom lại thành registry theo code()
public FeeCalculator(CourseRepository courseRepository, List<DiscountPolicy> policies) {
this.courseRepository = courseRepository;
this.policiesByCode = policies.stream()
.collect(Collectors.toMap(DiscountPolicy::code, Function.identity()));
}
public double calculate(Long courseId, String promoCode) {
if (courseRepository.isInternalCourse(courseId)) {
return 0;
}
double baseFee = courseRepository.loadBaseFee(courseId);
DiscountPolicy policy = policiesByCode.get(promoCode);
return policy == null ? baseFee : policy.apply(baseFee);
}
}
Giờ đến phép thử thật sự của OCP: thêm khuyến mãi BLACKFRIDAY. Ta chỉ cần thêm một class mới; FeeCalculator không đổi một dòng, và Spring tự động nhận bean mới vào registry qua constructor.
@Component
public class BlackFridayDiscount implements DiscountPolicy {
public String code() { return "BLACKFRIDAY"; }
public double apply(double baseFee) { return baseFee * 0.7; }
}
// FeeCalculator: không sửa. Chỉ thêm class trên là xong.
Ghi chú
Vì mỗi policy giờ là một class độc lập, việc kiểm thử cũng tách bạch: test từng policy riêng, và test rằng FeeCalculator chọn đúng policy theo mã.
class FeeCalculatorTest {
@Test
void ap_dung_black_friday_giam_30_phan_tram() {
CourseRepository repo = mock(CourseRepository.class);
when(repo.isInternalCourse(10L)).thenReturn(false);
when(repo.loadBaseFee(10L)).thenReturn(1_000_000.0);
// Chỉ cần đưa vào các policy cần test — không cần Spring container
FeeCalculator calculator = new FeeCalculator(repo, List.of(new BlackFridayDiscount()));
assertEquals(700_000.0, calculator.calculate(10L, "BLACKFRIDAY"));
}
@Test
void khong_co_ma_thi_giu_nguyen_hoc_phi() {
CourseRepository repo = mock(CourseRepository.class);
when(repo.isInternalCourse(10L)).thenReturn(false);
when(repo.loadBaseFee(10L)).thenReturn(1_000_000.0);
FeeCalculator calculator = new FeeCalculator(repo, List.of(new BlackFridayDiscount()));
assertEquals(1_000_000.0, calculator.calculate(10L, null));
}
}
Một lưu ý để hiểu OCP cho đúng: FeeCalculator vẫn có thể phải sửa vì những lý do khác — ví dụ nếu quy tắc "khóa nội bộ miễn phí" thay đổi. OCP không hứa class này bất biến tuyệt đối; nó chỉ đóng class quanh trục biến thể là loại khuyến mãi. Đóng quanh mọi trục cùng lúc là bất khả thi và không cần thiết — phần "khi nào áp dụng" sẽ nói rõ.
Một cách khác để đạt OCP: Template Method
Strategy không phải cách duy nhất. Khi khung xử lý về cơ bản cố định và chỉ một vài bước thay đổi theo biến thể, Template Method thường gọn hơn: một class cơ sở giữ khung quy trình (đóng), để các bước cụ thể cho lớp con hoàn thiện (mở). Ví dụ trong LMS: sinh chứng chỉ theo nhiều định dạng nhưng quy trình chung giống nhau — dựng phần đầu, thân, chân rồi ghép lại — chỉ khác cách render từng phần.
// Base class giữ khung quy trình cố định — ĐÓNG với sửa đổi
public abstract class CertificateGenerator {
// Template method: khung không đổi khi thêm định dạng mới
public final byte[] generate(Enrollment enrollment) {
byte[] head = renderHeader(enrollment);
byte[] body = renderBody(enrollment);
byte[] foot = renderFooter(enrollment);
return assemble(head, body, foot);
}
protected abstract byte[] renderHeader(Enrollment e);
protected abstract byte[] renderBody(Enrollment e);
protected abstract byte[] renderFooter(Enrollment e);
protected abstract byte[] assemble(byte[] head, byte[] body, byte[] foot);
}
// Thêm định dạng mới = thêm một lớp con, không sửa base — MỞ để mở rộng
@Component
public class PdfCertificateGenerator extends CertificateGenerator {
protected byte[] renderHeader(Enrollment e) { /* dựng header PDF */ return new byte[0]; }
protected byte[] renderBody(Enrollment e) { /* dựng nội dung PDF */ return new byte[0]; }
protected byte[] renderFooter(Enrollment e) { /* dựng footer PDF */ return new byte[0]; }
protected byte[] assemble(byte[] h, byte[] b, byte[] f) { /* ghép thành PDF */ return new byte[0]; }
}
Chọn giữa hai cách tùy tình huống: dùng Strategy khi toàn bộ thuật toán thay đổi và muốn ghép/đổi linh hoạt lúc chạy (như DiscountPolicy); dùng Template Method khi khung cố định và chỉ vài bước biến thiên. Cả hai đạt cùng mục tiêu OCP — giữ phần lõi đóng, cho phép thêm biến thể mà không sửa nó. Một khác biệt đáng lưu ý: Template Method dựa trên inheritance nên các lớp con phải tôn trọng Liskov Substitution (Chương 5), còn Strategy dựa trên composition nên thường linh hoạt hơn và ít ràng buộc phân cấp.
OCP xuất hiện trong thực tế như thế nào
Dấu hiệu vi phạm OCP dễ nhận nhất khi review code là một chuỗi if-else hoặc switch rẽ nhánh theo "loại", và nó phình thêm mỗi lần có loại mới. Cụ thể hơn: khi bạn thấy mình liên tục mở đúng một method để thêm một nhánh else if cho mỗi biến thể; khi một câu mô tả bug hoặc task bắt đầu bằng "mỗi lần thêm X thì phải sửa Y"; hoặc khi một switch (type) xuất hiện ở nhiều nơi và tất cả phải sửa cùng lúc khi thêm một type. Đó đều là tín hiệu nên cân nhắc Replace Conditional with Polymorphism.
Ba variation axis thường xuất hiện trong hệ thống doanh nghiệp. Một là cổng thanh toán: một khối điều kiện theo payment mode mà mỗi provider mới lại buộc sửa orchestrator. Hai là kênh thông báo: email, SMS và push cùng giữ một application intent nhưng khác adapter. Ba là xuất báo cáo theo định dạng: PDF, spreadsheet và CSV có cùng use case nhưng khác renderer. Trong Spring, container có thể inject toàn bộ beans cùng type dưới dạng List hoặc Map; application dùng capability contract thay vì tự duy trì chuỗi type check.
Case study đầy đủ: cổng thanh toán trong LMS
Case study sau được xây riêng cho domain LMS của sách. Khi một học viên ghi danh khóa trả phí, application phải thu tiền qua một cổng thanh toán. Phiên bản đầu chỉ có một provider; roadmap sau đó xác nhận thêm các provider phục vụ thị trường và phương thức thanh toán khác. PaymentGateway đã được gợi ra ở Chương 2; phần này phân tích trọn vẹn evidence, refactor và chi phí của extension point ấy. Cơ chế nhiều implementation dựa trên polymorphism và collection injection của Spring, không phụ thuộc tên provider cụ thể.
Bước 0 — điểm xuất phát vi phạm OCP. Phiên bản đầu tiên thường là một PaymentService rẽ nhánh theo tên cổng. Mỗi cổng mới buộc phải mở đúng method này ra sửa.
@Service
public class PaymentService {
public PaymentResult charge(String provider, PaymentRequest request) {
switch (provider) {
case "STRIPE":
// gọi Stripe SDK
return PaymentResult.success("STRIPE");
case "VNPAY":
// gọi VNPay API
return PaymentResult.success("VNPAY");
case "MOMO":
// gọi MoMo API
return PaymentResult.success("MOMO");
default:
throw new IllegalArgumentException("Cổng không hỗ trợ: " + provider);
}
}
}
Đoạn code này còn tệ hơn vẻ ngoài của nó. Không chỉ charge phải sửa cho mỗi cổng mới; khi nghiệp vụ cần thêm hoàn tiền, sẽ xuất hiện một switch (provider) thứ hai trong method refund, rồi một cái nữa cho queryStatus — cùng một chuỗi rẽ nhánh bị nhân bản ở nhiều nơi, tất cả phải sửa đồng bộ khi thêm cổng. Đó đúng là code smell "Switch Statements" mà Fowler mô tả.
Bước 1 — xác nhận trục biến thể. Trước khi mở, hãy dùng câu hỏi quyết định của OCP: trục "nhà cung cấp thanh toán" đã có bao nhiêu biến thể và còn tăng không? Ở đây đã có ba và roadmap đã xác nhận còn thêm — đủ evidence để áp dụng OCP, không phải speculative generality.
Bước 2 — trích abstraction. Định nghĩa PaymentGateway làm hợp đồng cố định mà PaymentService sẽ đóng quanh; mỗi cổng là một implementation.
// Abstraction cố định — PaymentService sẽ "đóng" quanh interface này
public interface PaymentGateway {
String provider(); // "STRIPE", "VNPAY", ...
PaymentResult charge(PaymentRequest request);
}
@Component
public class StripeGateway implements PaymentGateway {
private final StripeClient stripeClient; // client bên thứ ba — tạo bean qua @Configuration/@Bean (Chương 2)
public StripeGateway(StripeClient stripeClient) {
this.stripeClient = stripeClient;
}
public String provider() { return "STRIPE"; }
public PaymentResult charge(PaymentRequest request) {
// stripeClient.createCharge(...);
return PaymentResult.success("STRIPE");
}
}
@Component
public class VnPayGateway implements PaymentGateway {
public String provider() { return "VNPAY"; }
public PaymentResult charge(PaymentRequest request) {
// gọi VNPay API
return PaymentResult.success("VNPAY");
}
}
Bước 3 — làm PaymentService đóng. Nó nhận toàn bộ bean PaymentGateway từ Spring và gom thành registry theo mã cổng, y hệt kỹ thuật đã dùng cho DiscountPolicy.
@Service
public class PaymentService {
private final Map<String, PaymentGateway> gatewaysByProvider;
public PaymentService(List<PaymentGateway> gateways) {
this.gatewaysByProvider = gateways.stream()
.collect(Collectors.toMap(PaymentGateway::provider, Function.identity()));
}
public PaymentResult charge(String provider, PaymentRequest request) {
PaymentGateway gateway = gatewaysByProvider.get(provider);
if (gateway == null) {
throw new IllegalArgumentException("Cổng không hỗ trợ: " + provider);
}
return gateway.charge(request);
}
}
Bước 4 — phép thử OCP. Thêm ZaloPay chỉ là thêm một class; PaymentService không đổi một dòng, và một unit test chứng minh nó chọn đúng cổng mà không cần Spring container hay gọi API thật.
@Component
public class ZaloPayGateway implements PaymentGateway {
public String provider() { return "ZALOPAY"; }
public PaymentResult charge(PaymentRequest request) {
// gọi ZaloPay API
return PaymentResult.success("ZALOPAY");
}
}
class PaymentServiceTest {
@Test
void chon_dung_cong_theo_provider() {
PaymentGateway stripe = new StripeGateway(mock(StripeClient.class));
PaymentGateway zalo = new ZaloPayGateway();
PaymentService service = new PaymentService(List.of(stripe, zalo));
PaymentResult result = service.charge("ZALOPAY", new PaymentRequest(1L, 500_000.0));
assertEquals("ZALOPAY", result.provider());
}
@Test
void nem_loi_khi_cong_khong_ho_tro() {
PaymentService service = new PaymentService(List.of(new ZaloPayGateway()));
assertThrows(IllegalArgumentException.class,
() -> service.charge("PAYPAL", new PaymentRequest(1L, 500_000.0)));
}
}
Vài tình huống thực tế đi kèm mà một case study trung thực phải nói tới. Thứ nhất là cấu hình và secret riêng của mỗi cổng (API key, endpoint): giữ chúng ở lớp @Configuration với @Bean, hoặc để mỗi gateway tự đọc cấu hình qua @Value/@ConfigurationProperties — phần lắp ráp vẫn tách khỏi phần dùng, đúng tinh thần Chương 2. Thứ hai là chọn cổng mặc định khi người dùng không chỉ định: dùng @Primary/@Qualifier (Chương 2) hoặc một quy tắc theo vùng.
Thứ ba, và đáng suy nghĩ nhất: không phải cổng nào cũng hỗ trợ hoàn tiền. Nếu ta thêm refund vào PaymentGateway, những cổng không hoàn tiền sẽ buộc phải implement một method vô nghĩa — đúng loại vấn đề mà Interface Segregation Principle (Chương 6) giải quyết, có lẽ bằng một interface Refundable riêng. Và mọi implementation của charge phải tôn trọng cùng một hợp đồng (ví dụ không được âm thầm trả success khi giao dịch thất bại), nếu không phần điều phối "đóng" sẽ vỡ khi gặp một cổng hành xử lệch — đó là địa hạt của Liskov Substitution (Chương 5). Bài học: mở một abstraction theo OCP mới là bước đầu; giữ cho abstraction đó nhỏ gọn và các implementation trung thành với hợp đồng là việc của ISP và LSP.
Khi nào áp dụng OCP — và khi nào chưa nên mở extension point
OCP là nguyên lý dễ bị lạm dụng nhất, vì bản chất của nó là dự đoán biến thể, mà dự đoán thì khó. Nếu mở sẵn điểm mở rộng cho một trục không bao giờ biến thiên, bạn tạo ra thứ Martin Fowler gọi là speculative generality — một code smell: tầng abstraction, interface, factory dựng lên cho một tương lai không đến, chỉ làm code khó đọc hơn mà chẳng đem lại linh hoạt thực sự.
Vì vậy nguyên tắc thực dụng là: đừng áp OCP theo phản xạ, hãy áp đúng trục biến thể đã thật sự lộ ra. Một chuỗi if-else với hai nhánh cố định, không có dấu hiệu sẽ thêm nhánh mới, thì cứ để nguyên — nó dễ đọc hơn một loạt class nhỏ. Theo tinh thần YAGNI và Rule of Three (Chương 3): viết if-else lần đầu là ổn; đến khi biến thể thứ hai, thứ ba xuất hiện và bạn thấy mình sửa đi sửa lại đúng chỗ đó, đó mới là lúc trích ra abstraction theo OCP. Với domain của chúng ta, DiscountPolicy xứng đáng được mở vì loại khuyến mãi rõ ràng còn tăng theo thời gian; nhưng quy tắc "khóa nội bộ miễn phí" — hiện chỉ là một điều kiện đơn, không có triển vọng biến thể — thì chưa cần biến thành một hệ policy riêng.
Câu hỏi quyết định cho OCP: "Trên trục này, đã có (hoặc gần như chắc chắn sắp có) biến thể thứ hai, thứ ba chưa?" Nếu có, mở điểm mở rộng. Nếu chưa, giữ đơn giản và refactor đúng lúc biến thể thật xuất hiện — chi phí refactor lúc đó thường nhỏ hơn nhiều so với chi phí gánh một abstraction sai suốt thời gian dài.
“Đóng” luôn phải có mục tiêu. Robert C. Martin lưu ý rằng không hệ thống nào có thể đóng trước mọi loại thay đổi; closure phải mang tính chiến lược, ưu tiên những thay đổi phổ biến mà team đã trải nghiệm hoặc có bằng chứng sắp xảy ra. DiscountPolicy là điểm mở rộng hợp lý khi Soldemy đã có khuyến mãi theo phần trăm, theo số tiền và theo đối tác, đồng thời roadmap còn thêm chiến dịch mới. Ngược lại, điều kiện duy nhất “khóa học nội bộ có học phí bằng 0” chưa tạo thành một trục biến thể; biến nó thành InternalCourseFeePolicyFactory cùng nhiều interface chỉ làm một quy tắc đơn giản khó theo dõi hơn.
Một ngoại lệ quan trọng đối với việc chờ biến thể là khi refactor về sau rất đắt hoặc rủi ro: public SDK đã phát hành, schema/message contract có nhiều consumer, hay module được nhiều team deploy độc lập. Khi đó, chuẩn bị một extension point sớm có thể rẻ hơn migration tương thích ngược sau này, nhưng quyết định phải dựa trên consumer và kịch bản thay đổi cụ thể. Fowler mô tả YAGNI như một chiến lược thiết kế dựa vào khả năng refactor, không phải lệnh cấm suy nghĩ trước về architecture. Nếu thay đổi muộn khó đảo ngược, phần thiết kế chuẩn bị trước cần được chứng minh bằng chi phí migration, không bằng câu “sau này có thể cần”.
Vì vậy, hãy đánh giá OCP bằng bốn dữ kiện: số biến thể đã biết; tần suất thêm biến thể; blast radius mỗi lần sửa điểm điều phối; và chi phí đưa abstraction vào hôm nay so với refactor sau này. Nên mở khi biến thể có thật, thay đổi lặp lại và phần lõi đã đủ ổn định để đặt contract. Chưa nên mở khi yêu cầu lõi còn đổi liên tục, chỉ có một nhánh hoặc team chưa hiểu trục biến thể. Hãy ghi lại trigger để xem xét lại, chẳng hạn “khi xuất hiện policy thứ ba” hoặc “trước khi phát hành SDK cho đối tác”, thay vì biến lựa chọn hiện tại thành giáo điều.
OCP trong mối quan hệ với các nguyên lý khác
OCP không đứng một mình. Nó dựa vào SRP: chỉ khi logic khuyến mãi đã được tách gọn khỏi EnrollmentService (Chương 3), ta mới có một FeeCalculator đủ tập trung để mở quanh trục khuyến mãi. Nó dựa vào Dependency Inversion (Chương 7): FeeCalculator phụ thuộc vào abstraction DiscountPolicy chứ không vào các class cụ thể — đó chính là DIP đang làm nền cho OCP. Và nó cần Liskov Substitution (Chương 5): mọi implementation của DiscountPolicy phải thật sự thay thế được cho nhau qua interface, nếu không phần điều phối "đóng" sẽ vỡ khi gặp một policy hành xử lệch hợp đồng. Robert C. Martin từng nói OCP là mục tiêu, còn DIP là cơ chế chính để đạt mục tiêu đó — ta sẽ thấy rõ điều này ở Chương 7.
Những hiểu lầm phổ biến
"OCP nghĩa là không bao giờ được sửa code." Không. OCP chỉ nhắm vào việc thêm biến thể trên một trục đã biết; sửa để vá lỗi hay khi yêu cầu lõi đổi vẫn là bình thường.
"Cứ tạo interface cho mọi thứ là đạt OCP." Không. Interface cho một trục không biến thiên là speculative generality — chi phí thừa. OCP áp đúng trục thay đổi thật.
"OCP chỉ là dùng Strategy pattern." Strategy là một cách phổ biến, nhưng OCP là mục tiêu; Template Method, hay đơn giản là phụ thuộc vào một abstraction, cũng đạt OCP.
"Đã dùng
List<Interface>của Spring là xong OCP." Cơ chế inject giúp ích, nhưng nếu bạn vẫn phải sửa phần điều phối mỗi khi thêm biến thể thì chưa đóng thật sự. Kiểm tra bằng câu hỏi: thêm biến thể mới có phải sửa code cũ không?
Chương này nối với phần còn lại của sách ra sao
OCP là bước tiến tự nhiên sau SRP: tách trách nhiệm xong thì mở trách nhiệm đó cho biến thể tương lai. Từ đây, Chương 5 (Liskov Substitution) bảo đảm các biến thể mà OCP thêm vào thật sự thay thế được cho nhau mà không phá vỡ phần điều phối; Chương 6 (Interface Segregation) bảo đảm các abstraction ta mở ra đủ nhỏ và tập trung; Chương 7 (Dependency Inversion) làm rõ cơ chế phụ thuộc vào abstraction mà OCP dựa vào. Mỗi khi định mở một điểm mở rộng, hãy tự hỏi: trục này đã thật sự biến thiên chưa, và abstraction mình mở ra có được các implementation tôn trọng đầy đủ không?
Tóm tắt
OCP (Bertrand Meyer, 1988): software entity nên mở để mở rộng, đóng với sửa đổi — thêm hành vi mới bằng cách thêm code, không sửa code lõi đã ổn.
Chìa khóa là abstraction + polymorphism: module lõi phụ thuộc một interface cố định (đóng), hệ thống mở bằng cách thêm implementation.
Kỹ thuật chính là Strategy pattern / Replace Conditional with Polymorphism; trong Spring, inject
List/Mapcác bean cùng interface làm registry.OCP không phải "không bao giờ sửa code"; nó chỉ đóng quanh một trục biến thể cụ thể.
Dấu hiệu vi phạm: chuỗi if-else/switch theo type phình mỗi lần thêm loại; "mỗi lần thêm X phải sửa Y".
Khi nào áp dụng: đúng trục biến thể đã lộ và còn tăng; chưa nên mở sẵn cho trục không biến thiên vì đó là speculative generality. Câu hỏi quyết định: "đã có biến thể thứ hai hoặc thứ ba, hay consumer đã cam kết, chưa?".
Closure phải mang tính chiến lược; cân số biến thể, tần suất thay đổi, blast radius và chi phí refactor muộn. Public contract khó đổi có thể cần chuẩn bị sớm hơn code nội bộ.
OCP dựa vào SRP, DIP và LSP — không đứng một mình.
Bài tập cuối chương
Phần bài tập cuối chương yêu cầu bạn giải thích variation axis và chi phí của abstraction, không chỉ chuyển mọi nhánh điều kiện thành Strategy. Hãy bảo vệ cả quyết định tạo extension point lẫn quyết định chưa tạo nó khi evidence chưa đủ.