Có một loại code mà đội nào cũng có: một phương thức dài bảy mươi dòng, ngập trong if lồng nhau, mà không ai dám đụng vì nó tính tiền thật. Khi sếp yêu cầu "thêm mã giảm giá mới cho khách hàng mới", lập trình viên phải đọc cả bảy mươi dòng, chèn thêm một nhánh else if và cầu mong không làm hỏng nhánh khác. Bài này là một case study về refactoring: đưa đúng loại phương thức đó, chạy thật trên JDK 21, từ một khối if/else lên cấu trúc Strategy, theo từng bước nhỏ, và mỗi bước đều được một lưới kiểm thử 2.835 tổ hợp canh gác. Mục tiêu không phải khoe pattern, mà chỉ ra khi nào pattern xuất hiện một cách tự nhiên và khi nào nên dừng lại.
Bài nối tiếp Testing patterns: Test Data Builder, Object Mother và test double, nơi ta đã bàn về cách viết test bền, và Anti-pattern trong Java: God object, anemic model, service locator, nơi ta nhận diện các mùi thiết kế. Pattern nền tảng được dùng ở đây đã có bài riêng: Strategy pattern với lambda và functional interface. Code nằm trong code/s6-35, chạy bằng JDK 21.0.12 và JUnit 5.10.5 (qua spring-boot-starter-parent 3.3.5) trên Apple M4, 16 GB RAM.
Refactoring là gì, và vì sao "sang pattern" chứ không "bằng pattern"
Martin Fowler định nghĩa refactoring theo hai nghĩa: danh từ là một thay đổi cấu trúc bên trong giúp code dễ hiểu hơn và giảm chi phí cho các thay đổi sau, trong khi hành vi quan sát được từ bên ngoài không đổi; động từ là áp dụng một chuỗi các refactoring như vậy. Điều cốt lõi là vế sau: hành vi không đổi. Đổi hành vi (sửa lỗi, thêm tính năng) là việc khác, và trộn hai việc trong một commit là nguồn gốc của hầu hết rủi ro.
Joshua Kerievsky, trong cuốn Refactoring to Patterns (Addison-Wesley, 2004), đẩy ý đó xa hơn: theo trang giới thiệu của Industrial Logic, luận điểm trung tâm của sách là dùng pattern để cải thiện thiết kế có sẵn thì tốt hơn dùng pattern ngay từ đầu trong một thiết kế mới. Nói cách khác, pattern là đích đến của một chuỗi biến đổi nhỏ khi code đã cho thấy nỗi đau, không phải bản vẽ phải áp lên trước. Cách nhìn này tránh được căn bệnh "pattern-happy": dựng sẵn Factory, Strategy, Visitor cho một nghiệp vụ mà năm sau vẫn chỉ có hai nhánh.
Còn Replace Conditional with Polymorphism là kỹ thuật cụ thể mà ta sẽ dùng: khi một điều kiện chọn hành vi theo kiểu hoặc thuộc tính của đối tượng, ta thay nó bằng các đối tượng cùng giao diện, mỗi đối tượng giữ một nhánh. Hướng dẫn trên refactoring.guru đặt một bước chuẩn bị rõ ràng: nếu điều kiện nằm trong một phương thức còn làm việc khác, hãy Extract Method trước. Case study dưới đây đi đúng thứ tự đó.
Code gốc: một phương thức tính báo giá
Bài toán là hệ thống báo giá của một cửa hàng đa kênh. Hàm total nhận loại khách (RETAIL, VIP, DEALER), kênh bán (WEB, STORE, MARKETPLACE), miền, mã giảm giá, phương thức giao hàng và giỏ hàng, rồi trả về số tiền phải trả (đơn vị đồng). Quy tắc nghiệp vụ tích tụ qua nhiều đợt yêu cầu: VIP giảm 5%; đại lý giảm 3%, hoặc 12% khi đơn từ 5.000.000; mã GIAM10 giảm 10% nhưng chỉ khi tốt hơn mức đang có và trần giảm 500.000; FREESHIP miễn phí giao; NEWCUS trừ thẳng 50.000 cho đơn từ 300.000; mã không áp dụng ở kênh marketplace; cửa hàng không tính phí giao; giao tiêu chuẩn miễn phí từ 1.000.000 sau giảm; VAT 10%. Đây là đoạn áp mã giảm giá của bản gốc (LegacyQuoteCalculator, 73 dòng cho riêng phương thức total):
// mã giảm giá
long flat = 0;
boolean freeShip = false;
if (coupon != null) {
if (coupon.equals("GIAM10")) {
if (!channel.equals("MARKETPLACE")) {
if (percent < 10) {
percent = 10;
}
}
} else if (coupon.equals("FREESHIP")) {
if (!channel.equals("MARKETPLACE")) {
freeShip = true;
}
} else if (coupon.equals("NEWCUS")) {
if (!channel.equals("MARKETPLACE") && subtotal >= 300_000) {
flat = 50_000;
}
}
}
long discount = subtotal * percent / 100;
if (coupon != null && coupon.equals("GIAM10") && !channel.equals("MARKETPLACE") && percent == 10 && discount > 500_000) {
discount = 500_000;
}Phần phí giao hàng phía dưới còn dài hơn. Những mùi thấy ngay: phương thức làm năm việc, các cờ freeShip và flat là biến trung gian rò từ khối này sang khối kia, điều kiện !channel.equals("MARKETPLACE") lặp ba lần, và dòng 48 của tệp phải nhắc lại cả mã, kênh lẫn percent == 10 chỉ để biết trần 500.000 có áp dụng hay không. Công cụ đếm trong repo cho thấy phương thức có 29 điểm quyết định (if, case, for, &&, ||, ?); đây là số đếm thô, không phải cyclomatic complexity chuẩn, nhưng đủ để so sánh các phiên bản.
Bước 0 của refactoring: khoá hành vi bằng characterization test
Trước khi đổi một dòng, ta cần thứ bảo đảm "hành vi không đổi". Với code cũ không có test, cách thực dụng nhất là characterization test mà Michael Feathers mô tả trong Working Effectively with Legacy Code: một test ghi lại hành vi thực tế của code, kể cả khi hành vi đó có lẽ sai. Mục đích không phải kiểm tra đúng sai mà là phát hiện thay đổi ngoài ý muốn.
Ở đây ta dùng biến thể "golden master": chạy bản gốc trên một lưới đầu vào rộng, ghi kết quả ra tệp, rồi so từng tổ hợp. Lưới gồm 3 loại khách, 3 kênh, 3 miền, 5 giá trị mã (gồm null và một mã không tồn tại), 3 cách giao và 7 giỏ hàng: 3×3×3×5×3×7 = 2.835 tổ hợp. Bảy giỏ hàng không chọn ngẫu nhiên: bốn giỏ đại diện (nhỏ, vừa, lớn, rất lớn) và ba giỏ nằm đúng trên biên của các điều kiện >=, tức 300.000, 1.000.000 và 5.000.000.
Vì sao phải chú ý biên? Đây là bài học rút ra từ chính lần chạy đầu tiên của tôi. Với lưới chỉ có bốn giỏ đại diện (1.620 tổ hợp), tôi viết một đoạn script đột biến thủ công (mutate.sh): sửa một toán tử so sánh trong bản cuối, chạy lại test, xem test có đỏ không. Kết quả thật:
KHÔNG bị bắt (test vẫn xanh) | subtotal >= 5_000_000 ? 12 : 3 -> subtotal > 5_000_000 ? 12 : 3
KHÔNG bị bắt (test vẫn xanh) | ctx.subtotal() >= 300_000 -> ctx.subtotal() > 300_000
KHÔNG bị bắt (test vẫn xanh) | afterDiscount >= 1_000_000 || freeShip -> afterDiscount > 1_000_000 || freeShip
BỊ BẮT | Math.min(discount, 500_000) -> Math.min(discount, 400_000)
BỊ BẮT | new long\[\] {45_000, 60_000, 55_000} -> new long[] {45_000, 60_000, 56_000}Ba đột biến đổi >= thành > ở đúng các ngưỡng 5.000.000, 300.000 và 1.000.000 đều không bị bắt, dù lưới có tới 1.620 tổ hợp. Hai đột biến đổi hằng số (trần giảm giá, phí giao hàng nhanh) thì bị bắt. Lý do: không giỏ hàng nào có tổng đúng bằng ngưỡng nên >= và > cho cùng kết quả. Một lưới "nhiều dòng" chưa chắc là một lưới tốt. Sau khi thêm ba giỏ nằm trên biên (lưới thành 2.835 tổ hợp), chạy lại:
BỊ BẮT | subtotal >= 5_000_000 ? 12 : 3 -> subtotal > 5_000_000 ? 12 : 3
BỊ BẮT | ctx.subtotal() >= 300_000 -> ctx.subtotal() > 300_000
BỊ BẮT | afterDiscount >= 1_000_000 || freeShip -> afterDiscount > 1_000_000 || freeShip
BỊ BẮT | Math.min(discount, 500_000) -> Math.min(discount, 400_000)
BỊ BẮT | new long\[\] {45_000, 60_000, 55_000} -> new long[] {45_000, 60_000, 56_000}Cả năm đột biến đều bị bắt. Đó là tấm lưới ta cần. Đoạn so sánh nằm trong một @TestFactory của JUnit 5, sinh một test cho mỗi phiên bản code và so với tệp golden.csv:
@TestFactory
Stream<DynamicTest> everyVersionMatchesGoldenMaster() throws IOException {
Map<String, Long> golden = golden();
return VERSIONS.entrySet().stream().map(e -> DynamicTest.dynamicTest(e.getKey(), () -> {
int n = 0;
for (Grid.Case c : Grid.cases()) {
long actual = e.getValue().total(c.customerType(), c.channel(), c.region(), c.coupon(),
c.shipping(), Grid.cart(c.cart()));
assertEquals(golden.get(c.key()), actual, () -> e.getKey() + " lệch ở " + c.key());
n++;
}
System.out.println(e.getKey() + ": " + n + "/" + golden.size() + " tổ hợp khớp golden master");
}));Bước 1 của refactoring: Extract Method, tách từng quy tắc có tên
Bước refactoring đầu tiên chưa đụng gì tới pattern. Ta chỉ đặt tên cho từng khối: subtotal, customerPercent, couponEffect, shippingFee. Biến trung gian (percent, flat, freeShip, cờ trần) rò khắp nơi được gom thành một record CouponEffect, một giá trị bất biến trả về từ một chỗ. Phương thức chính giờ đọc như một lời kể:
@Override
public long total(String customerType, String channel, String region, String coupon, String shipping, List<Line> lines) {
long subtotal = subtotal(lines);
CouponEffect effect = couponEffect(coupon, channel, subtotal, customerPercent(customerType, subtotal));
long discount = subtotal * effect.percent() / 100;
if (effect.capped() && discount > 500_000) {
discount = 500_000;
}
long afterDiscount = subtotal - discount - effect.flat();
long fee = shippingFee(channel, shipping, region, afterDiscount, effect.freeShip());
return afterDiscount + afterDiscount * 10 / 100 + fee;
}Thay đổi đáng chú ý: điều kiện "mã không áp dụng ở marketplace" chỉ còn một chỗ (đầu couponEffect) thay vì ba. Điều kiện trần giảm giá cũng không còn phải suy diễn từ percent == 10, vì CouponEffect mang theo cờ capped. Chạy test:
v0 legacy: 2835/2835 tổ hợp khớp golden master
v1 extract: 2835/2835 tổ hợp khớp golden master
v2 strategy: 2835/2835 tổ hợp khớp golden master
v3 coupon: 2835/2835 tổ hợp khớp golden master
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- in demo.quote.CharacterizationTest
GIAM20 trên 1.000.000, miền Bắc, giao tiêu chuẩn: 905000
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in demo.quote.NewCouponTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSDòng v1 extract: 2835/2835 tổ hợp khớp golden master xác nhận hành vi không đổi. Extract Method là thao tác mà IDE làm tự động được, nên rủi ro rất thấp.
Bước 2 của refactoring: Replace Conditional with Polymorphism
Sau bước refactoring đầu, hai chuỗi điều kiện còn lại cùng một dạng: chọn hành vi theo một chuỗi (loại khách, phương thức giao). Đây đúng là dấu hiệu mà kỹ thuật polymorphism nhắm vào. Với Java 21 ta không cần một cây lớp: interface một phương thức cộng với lambda là đủ (xem lại bài Strategy pattern với lambda), đăng ký trong Map theo khoá chuỗi:
private static final Map<String, CustomerPolicy> CUSTOMERS = Map.of(
"VIP", subtotal -> 5,
"DEALER", subtotal -> subtotal >= 5_000_000 ? 12 : 3);
/** Strategy 2: cách tính phí vận chuyển. */
interface ShippingPolicy {
long fee(String region, long afterDiscount, boolean freeShip);
ShippingPolicy FREE = (region, afterDiscount, freeShip) -> 0;
} private static final Map<String, ShippingPolicy> SHIPPINGS = Map.of(
"STANDARD", (region, after, free) -> (after >= 1_000_000 || free) ? 0 : new Fees(25_000, 35_000, 30_000).of(region),
"EXPRESS", (region, after, free) -> free ? 0 : new Fees(45_000, 60_000, 55_000).of(region));Hành vi mặc định cũng được giữ nguyên bằng getOrDefault(..., NONE): loại khách lạ không giảm, phương thức giao lạ (như PICKUP) miễn phí. Đây chính là chi tiết mà characterization test bảo vệ, vì bản gốc không ném lỗi với giá trị lạ. Nếu bạn muốn "sửa" thành ném lỗi, đó là một thay đổi hành vi, phải làm ở commit riêng sau khi refactoring xong.
Cần dừng lại hỏi thẳng: Strategy này có đáng không? Với hai biến thể mỗi loại, Map.of cùng lambda gần như chỉ là bảng tra. Nó đáng khi biến thể còn tăng hoặc cần test riêng; nếu không, v1 đã đủ tốt.
Bước 3 của refactoring: Strategy cho mã giảm giá
Trong refactoring này, hầu hết yêu cầu mới của nghiệp vụ là mã giảm giá: chiến dịch nào cũng có mã mới. Vì vậy chỗ này mới là nơi pattern trả lãi. Ta tách Coupon thành một interface với một phương thức nhận ngữ cảnh (Context: kênh, tổng giỏ, phần trăm cơ sở) và trả về Effect:
/** Strategy 3: mỗi mã giảm giá là một đối tượng; thêm mã mới không phải sửa calculator. */
public interface Coupon {
/** Tác động của mã lên một đơn cụ thể. */
record Effect(long percent, long flat, boolean freeShip, boolean capped) {
static Effect none(long base) {
return new Effect(base, 0, false, false);
}
}
/** Ngữ cảnh đã tính sẵn mà mã có thể dùng. */
record Context(String channel, long subtotal, long basePercent) {}
Effect apply(Context ctx);
}Calculator nhận Map<String, Coupon> qua constructor (cùng ý tưởng với Dependency Injection), nên một mã mới có thể đăng ký mà không sửa lớp nào có sẵn. Phần cốt lõi giờ là một pipeline bốn bước, mỗi bước chỉ một dòng:
Effect effect = effectOf(coupon, new Context(channel, subtotal, base));
long discount = subtotal * effect.percent() / 100;
if (effect.capped()) discount = Math.min(discount, 500_000);
long afterDiscount = subtotal - discount - effect.flat();
long fee = channel.equals("STORE") ? 0 : shippingFee(shipping, region, afterDiscount, effect.freeShip());
return afterDiscount + afterDiscount * 10 / 100 + fee;
}Test cuối chứng minh điều ta muốn: thêm mã GIAM20 chỉ bằng một dòng đăng ký trong code gọi, không sửa CouponQuoteCalculator:
@Test
void themMaGiam20ChoKhachMoi() {
Map<String, demo.quote.v3.Coupon> coupons = new HashMap<>();
coupons.put("GIAM20", ctx -> new Effect(20, 0, false, true));
CouponQuoteCalculator calc = new CouponQuoteCalculator(coupons);
// 1.000.000 - 20% = 800.000; VAT 80.000; STANDARD 25.000 vì sau giảm < 1.000.000 (miền Bắc)
long total = calc.total("RETAIL", "WEB", "NORTH", "GIAM20", "STANDARD", List.of(new Line("SKU-3", 1, 1_000_000)));
System.out.println("GIAM20 trên 1.000.000, miền Bắc, giao tiêu chuẩn: " + total);
assertEquals(905_000, total);
}Kết quả nằm trong output ở trên (GIAM20 trên 1.000.000, miền Bắc, giao tiêu chuẩn: 905000): 1.000.000 trừ 20% còn 800.000, cộng VAT 80.000 và phí giao tiêu chuẩn miền Bắc 25.000, tổng 905.000, đúng bằng phép tính tay. Test này khác với characterization test ở chỗ nó mô tả hành vi mới, nên chỉ được viết sau khi refactoring hoàn tất.
Đo lại: refactoring có thật sự cải thiện không
Ta không muốn tự khen. Script metrics.py đếm dòng và điểm quyết định của phương thức total trên từng phiên bản:
phiên bản dòng total() điểm quyết định total() điểm quyết định cả file
v0 legacy 73 29 29
v1 extract 11 2 21
v2 strategy 11 3 16
v3 coupon 14 5 16Sau refactoring, phương thức chính giảm từ 73 dòng, 29 điểm quyết định xuống 11 đến 14 dòng và chỉ vài điểm quyết định. Nhưng có hai điều phải nói thẳng. Thứ nhất, tổng điểm quyết định của cả file chỉ giảm từ 29 xuống 16: logic không biến mất, nó chỉ được chia nhỏ và đặt tên. Thứ hai, từ v2 sang v3 số điểm quyết định của total tăng từ 3 lên 5 vì tôi đưa phép chọn loại khách vào switch ngay trong phương thức; số liệu này phản ánh đúng code thực, không phải một chỉ số để tối ưu. Cái thu được thật sự là khả năng thay đổi cục bộ: đổi quy tắc một mã giảm giá giờ chỉ chạm một lambda, và test lưới sẽ báo ngay nếu lỡ tay.
Chi phí cũng có: số tệp tăng và người mới phải hiểu thêm Coupon, Context, Effect. Với nghiệp vụ ba mã ít đổi, v1 là điểm cân bằng tốt hơn v3.
Những pattern khác thường xuất hiện khi refactoring
Case study này đi tới Strategy, nhưng với mùi khác, đích đến sẽ khác. Theo danh mục của Kerievsky (27 refactoring hướng pattern, 12 mùi thiết kế), vài cặp thường gặp:
| Mùi trong code | Refactoring | Pattern đích |
|---|---|---|
Chuỗi if chọn thuật toán theo kiểu | Replace Conditional with Polymorphism | Strategy (bài này) |
Trạng thái đơn hàng điều khiển hành vi bằng switch rải rác | Replace State-Altering Conditionals with State | State |
| Nhiều lớp con chỉ khác một bước trong cùng khung thuật toán | Form Template Method | Template Method |
| Tạo đối tượng bằng constructor dài, nhiều biến thể | Replace Constructors with Creation Methods | Builder / Factory |
| Một hành động cần nhiều bước thông báo cho nhiều bên | Replace Hard-Coded Notifications with Observer | Observer |
Khi nào không nên refactoring sang pattern
- Không có lưới an toàn: nếu chưa viết được characterization test, đừng refactoring cấu trúc. Hãy bắt đầu bằng việc dựng lưới như ở bước 0.
- Code không thay đổi: phương thức xấu nhưng ổn định suốt ba năm không cần đụng; refactoring đáng giá ở chỗ nghiệp vụ còn đổi.
- Pattern nhiều hơn vấn đề (refactoring quá tay): hai nhánh không cần Strategy. Quy tắc ba lần (rule of three) là một chỉ dẫn hợp lý: lần thứ ba cùng một dạng thay đổi mới là lúc tổng quát hoá.
- Trộn hành vi và cấu trúc: sửa lỗi nghiệp vụ trong cùng commit với refactoring làm lưới golden vô dụng, vì không còn phân biệt được lệch do refactoring hay do sửa lỗi.
Lỗi thường gặp khi refactoring
- Lưới test chỉ dài, không sâu: như ba đột biến thoát lưới ở trên. Luôn kiểm tra biên của mọi điều kiện so sánh, và nếu có thể hãy chạy một công cụ mutation testing (ví dụ PIT) thay vì đột biến tay.
- Refactoring một lần quá lớn: bước nào cũng nên đủ nhỏ để khi lưới đỏ, ta biết ngay do thay đổi nào. Trong case study, mỗi phiên bản v1, v2, v3 là một lớp riêng và đáng là một commit riêng.
- Đổi chữ ký công khai giữa chừng: ở đây
QuoteCalculatorgiữ nguyên chữ ký qua cả bốn phiên bản, nên nơi gọi không phải sửa. Nếu cần đổi giao diện, hãy dùng bước trung gian (adapter) rồi mới di chuyển dần. - Golden master ghi luôn lỗi: tệp golden mô tả hành vi hiện tại, kể cả lỗi. Nếu bạn nghi một kết quả sai, hãy ghi chú và xử lý ở bước sửa lỗi riêng, đừng lặng lẽ "sửa" tệp golden.
Checklist refactoring sang pattern
- Xác định nỗi đau cụ thể (thay đổi nào thường xuyên xảy ra?) trước khi nghĩ tới pattern.
- Dựng characterization test với lưới có cả giá trị biên; kiểm tra lưới bằng đột biến.
- Tách cấu trúc và hành vi: refactoring ở commit riêng, không sửa lỗi cùng lúc.
- Extract Method trước, polymorphism sau, theo trình tự refactoring.guru khuyến nghị.
- Giữ chữ ký công khai không đổi; so mọi phiên bản trên cùng một lưới.
- Đo lại kết quả refactoring (dòng, điểm quyết định) và trung thực về chi phí: số tệp, độ gián tiếp.
- Dừng ở mức đơn giản nhất giải quyết được nỗi đau; pattern không phải đích.
Tóm lại, refactoring sang pattern là kỷ luật chứ không phải nghệ thuật: khoá hành vi, đi từng bước nhỏ, để pattern xuất hiện ở nơi nghiệp vụ thật sự thay đổi, và dừng đúng lúc. Nhờ lưới 2.835 tổ hợp, ta đã đi từ một phương thức 73 dòng đến một pipeline mở rộng được mà không đổi một đồng nào trong kết quả.
Bài tiếp theo
Bài cuối của series nhìn lại một công cụ ngôn ngữ đã giúp ta rất nhiều trong các bước trên: record bất biến và sealed class để mô hình hoá dữ liệu. Mời bạn đọc tiếp Record và sealed class: mô hình dữ liệu bất biến với Java record.



