Cửa hàng online cần tính phí vận chuyển theo nhiều cách: giao tiêu chuẩn, giao hoả tốc, miễn phí cho đơn từ 500.000 đồng, và tuần sau marketing sẽ nghĩ ra một cách thứ tư. Nếu logic này nằm trong một chuỗi if/else if khổng lồ bên trong CheckoutService, mỗi lần thêm quy tắc bạn phải sửa lại lớp đã chạy ổn định. Strategy pattern tách từng thuật toán ra thành một đối tượng thay thế được, để lớp gọi chỉ biết "có một cách tính phí" mà không cần biết cách nào. Bài này viết Strategy pattern theo hai cách, kiểu lớp truyền thống của GoF và kiểu lambda của Java 8 trở lên, chạy thật trên JDK 21 (biên dịch với --release 17), rồi bàn về những chỗ lambda giúp và những chỗ nó gây hại.
Bài trước trong series là Bridge và Flyweight pattern: tách trừu tượng và chia sẻ trạng thái, nơi ta đã nhắc rằng Bridge và Strategy có cấu trúc giống nhau. Nếu cần ôn nguyên lý nền, Nguyên lý SOLID trong Java qua ví dụ thực tế giải thích vì sao Open/Closed là động lực chính của pattern này.
Vấn đề mà Strategy pattern giải quyết: chuỗi if/else cho biến thể thuật toán
Đoạn code chưa dùng Strategy pattern thường trông như sau: nhận một chuỗi shippingType, rẽ nhánh bằng switch, mỗi nhánh tính phí theo công thức riêng. Nó chạy được, nhưng có ba vấn đề mà Strategy pattern sinh ra để giải quyết. Thứ nhất, thêm quy tắc nghĩa là sửa lớp cũ, vi phạm Open/Closed. Thứ hai, mọi công thức dồn vào một chỗ, khó kiểm thử riêng từng công thức. Thứ ba, chọn công thức (do khách hàng chọn, do cấu hình, do khuyến mãi) bị trộn với việc tính toán.
Theo refactoring.guru, Strategy pattern là pattern hành vi: đặt mỗi thuật toán vào lớp riêng để hoán đổi mà không đổi code của bên dùng. Nó phù hợp khi bạn cần đổi biến thể thuật toán lúc chạy, khi nhiều lớp chỉ khác nhau ở một hành vi, hoặc khi muốn tách logic nghiệp vụ khỏi chi tiết thuật toán.
Cấu trúc Strategy pattern kiểu GoF
| Vai trò | Nhiệm vụ | Trong ví dụ phí vận chuyển |
|---|---|---|
| Strategy | Interface khai báo phương thức duy nhất mà context gọi | ShippingStrategy.fee(Order) |
| Concrete Strategy | Mỗi lớp cài một biến thể thuật toán | StandardShipping, ExpressShipping, FreeShippingOver500k |
| Context | Giữ tham chiếu tới strategy và uỷ quyền | Checkout |
| Client | Chọn strategy cụ thể và đưa cho context | main |
Đây là cài đặt Strategy pattern thuần kiểu GoF, một lớp cho mỗi chiến lược:
import java.util.List;
public class StrategyClassic {
interface ShippingStrategy {
long fee(Order order);
}
record Order(String id, long amount, double weightKg, String region) { }
static final class StandardShipping implements ShippingStrategy {
public long fee(Order o) { return 20_000 + Math.round(Math.ceil(o.weightKg())) * 5_000; }
}
static final class ExpressShipping implements ShippingStrategy {
public long fee(Order o) { return 45_000 + Math.round(Math.ceil(o.weightKg())) * 12_000; }
}
static final class FreeShippingOver500k implements ShippingStrategy {
public long fee(Order o) { return o.amount() >= 500_000 ? 0 : new StandardShipping().fee(o); }
}
/** Context: giữ strategy và uỷ quyền. */
static final class Checkout {
private ShippingStrategy strategy;
Checkout(ShippingStrategy s) { this.strategy = s; }
void setStrategy(ShippingStrategy s) { this.strategy = s; }
long total(Order o) { return o.amount() + strategy.fee(o); }
}
public static void main(String[] args) {
Order small = new Order("DH-1", 300_000, 1.2, "HN");
Order big = new Order("DH-2", 800_000, 3.5, "HCM");
List<ShippingStrategy> all = List.of(new StandardShipping(), new ExpressShipping(), new FreeShippingOver500k());
for (ShippingStrategy s : all) {
Checkout c = new Checkout(s);
System.out.printf("%-22s %s=%,d %s=%,d%n", s.getClass().getSimpleName(),
small.id(), c.total(small), big.id(), c.total(big));
}
}
}Kết quả chạy thật (java -cp out StrategyClassic). Kiểm tay: đơn DH-1 giá 300.000, nặng 1,2 kg làm tròn lên 2 kg, phí tiêu chuẩn 20.000 + 2 × 5.000 = 30.000, tổng 330.000:
StandardShipping DH-1=330,000 DH-2=840,000
ExpressShipping DH-1=369,000 DH-2=893,000
FreeShippingOver500k DH-1=330,000 DH-2=800,000Cách này đúng và rõ ràng, nhưng với Strategy pattern chỉ có một phương thức duy nhất, ba lớp cộng một interface là khá nhiều nghi thức. Đây chính là lý do ở Java hiện đại, Strategy pattern thường được viết bằng lambda.
Strategy pattern với lambda và functional interface
ShippingStrategy chỉ có đúng một phương thức trừu tượng, tức là một functional interface. Theo tài liệu @FunctionalInterface của Java SE 17, interface như vậy có đúng một phương thức trừu tượng; default method và các phương thức trùng với Object không được tính. Một lambda hoặc method reference có thể được gán vào bất kỳ functional interface nào, nhờ target typing: trình biên dịch suy ra kiểu của lambda từ ngữ cảnh. Chính refactoring.guru cũng ghi nhận trong phần nhược điểm rằng ngôn ngữ có tính năng hàm có thể thay đối tượng strategy bằng lambda, tránh phải khai báo thêm lớp và interface.
Phiên bản lambda của Strategy pattern cho cùng bài toán. Lưu ý @FunctionalInterface không bắt buộc về mặt kỹ thuật, nhưng nó cho trình biên dịch báo lỗi nếu ai đó vô tình thêm phương thức trừu tượng thứ hai:
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.function.ToLongFunction;
public class StrategyLambda {
record Order(String id, long amount, double weightKg, String region) { }
/** Giữ interface có tên khi muốn tài liệu hoá ý nghĩa nghiệp vụ; lambda vẫn gán được vào đây. */
@FunctionalInterface
interface ShippingStrategy {
long fee(Order order);
// default method cho phép "ghép" strategy: tương tự Comparator.thenComparing
default ShippingStrategy withCap(long max) { return o -> Math.min(max, fee(o)); }
default ShippingStrategy plusSurcharge(long extra) { return o -> fee(o) + extra; }
}
static final ShippingStrategy STANDARD = o -> 20_000 + (long) Math.ceil(o.weightKg()) * 5_000;
static final ShippingStrategy EXPRESS = o -> 45_000 + (long) Math.ceil(o.weightKg()) * 12_000;
static final ShippingStrategy FREE_OVER_500K = o -> o.amount() >= 500_000 ? 0 : STANDARD.fee(o);
static long total(Order o, ShippingStrategy s) { return o.amount() + s.fee(o); }Trong Strategy pattern dạng lambda, ba hằng STANDARD, EXPRESS, FREE_OVER_500K thay cho ba lớp. Thêm quy tắc mới vào Strategy pattern chỉ là thêm một dòng. Đoạn tiếp theo là phần còn lại của cùng lớp StrategyLambda (khối trước và khối này nối liền thành file code/s6-12/src/StrategyLambda.java). Phần main trình diễn sáu cách dùng, từ registry theo khoá cấu hình đến ghép strategy:
public static void main(String[] args) {
Order small = new Order("DH-1", 300_000, 1.2, "HN");
Order big = new Order("DH-2", 800_000, 3.5, "HCM");
// 1) Chọn strategy theo khoá cấu hình: Map thay cho chuỗi if/else hay switch
Map<String, ShippingStrategy> registry = new LinkedHashMap<>();
registry.put("standard", STANDARD);
registry.put("express", EXPRESS);
registry.put("free500k", FREE_OVER_500K);
registry.forEach((name, s) ->
System.out.printf("%-9s %s=%,d %s=%,d%n", name, small.id(), total(small, s), big.id(), total(big, s)));
// 2) Strategy viết tại chỗ, không cần lớp mới
long mienPhiHN = total(small, o -> o.region().equals("HN") ? 0 : 30_000);
System.out.printf("Lambda tại chỗ (miễn phí nội thành HN): %,d%n", mienPhiHN);
// 3) Method reference: strategy là một phương thức tĩnh có sẵn
ShippingStrategy flat = StrategyLambda::flatFee;
System.out.printf("Method reference (đồng giá): %,d%n", total(big, flat));
// 4) Ghép strategy bằng default method
ShippingStrategy expressCapped = EXPRESS.withCap(80_000);
System.out.printf("Express giới hạn 80.000: phí DH-1=%,d, DH-2=%,d (không giới hạn: %,d)%n",
expressCapped.fee(small), expressCapped.fee(big), EXPRESS.fee(big));
System.out.printf("Standard + phụ phí 10.000: %,d%n", STANDARD.plusSurcharge(10_000).fee(small));
// 5) Enum giữ strategy
for (Carrier c : Carrier.values()) {
System.out.printf("%-15s phí DH-2 = %,d%n", c, c.strategy.fee(big));
}
// 6) Dùng interface có sẵn của JDK thay vì tự khai báo
ToLongFunction<Order> weightFee = o -> (long) (o.weightKg() * 3_000);
Function<Order, String> label = o -> o.id() + "/" + o.region();
System.out.println(label.apply(big) + " phí theo cân = " + weightFee.applyAsLong(big));
}
static long flatFee(Order o) { return 25_000; }
/** Enum giữ strategy: tập chiến lược đóng, có tên ổn định để lưu vào DB. */
enum Carrier {
GHN(STANDARD), GHTK(FREE_OVER_500K), VIETTEL_EXPRESS(EXPRESS);
final ShippingStrategy strategy;
Carrier(ShippingStrategy s) { this.strategy = s; }
}
}Kết quả chạy thật (java -cp out StrategyLambda):
standard DH-1=330,000 DH-2=840,000
express DH-1=369,000 DH-2=893,000
free500k DH-1=330,000 DH-2=800,000
Lambda tại chỗ (miễn phí nội thành HN): 300,000
Method reference (đồng giá): 825,000
Express giới hạn 80.000: phí DH-1=69,000, DH-2=80,000 (không giới hạn: 93,000)
Standard + phụ phí 10.000: 40,000
GHN phí DH-2 = 40,000
GHTK phí DH-2 = 0
VIETTEL_EXPRESS phí DH-2 = 93,000
DH-2/HCM phí theo cân = 10500Ba dòng đầu giống hệt kết quả của bản dùng lớp, chứng tỏ hai cách viết Strategy pattern tương đương về hành vi. Những dòng sau cho thấy cái mà lambda mang lại nhiều hơn một cách viết gọn:
- Registry thay cho switch: một
Map<String, ShippingStrategy>ánh xạ khoá cấu hình sang strategy. Chọn công thức thành một lần tra bảng; thêm công thức không phải đụng vào code chọn. - Strategy viết tại chỗ: quy tắc dùng một lần, như "miễn phí nội thành HN", không cần đặt tên hay tạo lớp.
- Method reference: một phương thức tĩnh có sẵn (
StrategyLambda::flatFee) trở thành strategy nếu chữ ký khớp. - Ghép strategy bằng default method:
withCap(80_000)trả về strategy mới bao quanh strategy cũ. Phí Express của DH-2 giảm từ 93.000 xuống 80.000. Cách này giốngComparator.thenComparingtrong JDK. - Enum giữ strategy:
Carriergắn mỗi nhà vận chuyển với một strategy. Tập chiến lược đóng, tên enum ổn định để lưu vào cơ sở dữ liệu, điều mà lambda đứng một mình không làm được.
Strategy pattern trong JDK: Comparator
Ví dụ quen thuộc nhất của Strategy pattern trong JDK là java.util.Comparator. Phương thức List.sort(Comparator) là context: thuật toán sắp xếp cố định, còn "thế nào là nhỏ hơn" là strategy truyền vào. Theo tài liệu Comparator của Java SE 17, đây là một functional interface có phương thức compare, kèm các phương thức tĩnh và default như comparing, thenComparing, reversed để ghép các strategy nhỏ thành strategy lớn:
public class JdkStrategy {
record Product(String name, long price, int sold) { }
public static void main(String[] args) {
List<Product> ps = new ArrayList<>(List.of(
new Product("Bàn phím", 900_000, 120),
new Product("Chuột", 250_000, 300),
new Product("Tai nghe", 600_000, 120),
new Product("Lót chuột", 100_000, 500)));
ps.sort(Comparator.comparingLong(Product::price));
System.out.println("Giá tăng dần : " + names(ps));
ps.sort(Comparator.comparingInt(Product::sold).reversed().thenComparing(Product::name));
System.out.println("Bán chạy, rồi tên : " + names(ps));
ps.sort(Comparator.comparing(Product::name, String.CASE_INSENSITIVE_ORDER));
System.out.println("Theo tên : " + names(ps));
}
static List<String> names(List<Product> ps) { return ps.stream().map(Product::name).toList(); }Chạy java -cp out JdkStrategy:
Giá tăng dần : [Lót chuột, Chuột, Tai nghe, Bàn phím]
Bán chạy, rồi tên : [Lót chuột, Chuột, Bàn phím, Tai nghe]
Theo tên : [Bàn phím, Chuột, Lót chuột, Tai nghe]Dòng thứ hai cho thấy sức mạnh của việc ghép Strategy pattern: sắp theo số lượng bán giảm dần, hai sản phẩm cùng bán 120 thì tiếp tục sắp theo tên, nên "Bàn phím" đứng trước "Tai nghe". Cùng ý tưởng Strategy pattern xuất hiện ở Collections.sort, TreeMap hay Stream.sorted, và với Predicate trong Stream.filter hay Function trong Stream.map. Package java.util.function cung cấp sẵn các functional interface đa dụng như Function, Predicate, UnaryOperator, BiFunction, nên nhiều khi Strategy pattern không cần interface tự khai báo.
Khi nào dùng lambda, khi nào dùng lớp cho Strategy pattern
Lambda không luôn tốt hơn lớp trong Strategy pattern. Bảng sau là cách chọn thực dụng khi triển khai Strategy pattern:
| Tình huống | Nên dùng |
|---|---|
| Thuật toán một dòng, dùng một chỗ | Lambda tại chỗ |
| Dùng lại ở nhiều nơi, cần tên và tài liệu nghiệp vụ | Hằng lambda gán vào interface có tên (như STANDARD) |
| Strategy có trạng thái, cấu hình, phụ thuộc (kết nối, cache) | Lớp riêng, thường là bean do container quản lý |
| Nhiều phương thức liên quan cần cài cùng nhau | Lớp, vì lambda chỉ đại diện một phương thức |
| Tập chiến lược đóng, lưu tên vào DB | Enum giữ strategy |
| Cần log, giám sát tên chiến lược | Lớp có tên hoặc enum, vì lambda không có tên nghiệp vụ |
Refactoring.guru cũng lưu ý: nếu chỉ có vài thuật toán ổn định, việc thêm lớp và interface là phức tạp thừa, và client phải hiểu khác biệt giữa các strategy để chọn đúng. Với Spring, Strategy pattern thường được dùng bằng cách đăng ký các bean cùng implement một interface rồi inject dưới dạng Map<String, ShippingStrategy> theo tên bean, cho cùng hiệu quả registry mà không cần code đăng ký.
Phân biệt Strategy pattern với State, Bridge và Command
- Strategy pattern và State: cùng cấu trúc composition. Khác biệt chính là các strategy độc lập, không biết nhau; còn các state có thể biết và tự chuyển sang state khác.
- Strategy pattern và Bridge: bài trước đã nói, Bridge tách hai chiều của thiết kế, Strategy hoán đổi thuật toán thay thế được cho nhau.
- Strategy pattern và Command: Command biến một thao tác thành đối tượng để xếp hàng, lưu lịch sử, thực thi trễ; Strategy mô tả các cách khác nhau để làm cùng một việc trong một context.
Lỗi thường gặp khi dùng lambda làm Strategy
Một số bẫy của Strategy pattern kiểu lambda chỉ lộ ra khi chạy thật. Đoạn mã sau tập hợp bốn bẫy, mỗi bẫy một đoạn ngắn:
public static void main(String[] args) {
// 1) toString của lambda không mang ý nghĩa nghiệp vụ -> khó log/debug
LongUnaryOperator vat = x -> x * 110 / 100;
System.out.println("toString lambda: " + vat.toString().replaceAll("/0x[0-9a-f]+@[0-9a-f]+", "/0x...@..."));
System.out.println("Lớp của lambda là hidden class: " + vat.getClass().isHidden());
// 2) Biến bắt trong lambda phải effectively final; trạng thái đổi phải nằm trong đối tượng
int rate = 10;
LongUnaryOperator withRate = x -> x * (100 + rate) / 100;
// rate++; // bỏ comment dòng này sẽ lỗi biên dịch: local variables referenced from a lambda expression must be final or effectively final
System.out.println("Giá có VAT 10%: " + withRate.applyAsLong(1_000_000));
// 3) Strategy có trạng thái dùng chung giữa các luồng: lambda bắt List có thể gây lỗi
List<Long> audit = new ArrayList<>();
LongUnaryOperator audited = x -> { audit.add(x); return x; };
audited.applyAsLong(5);
audited.applyAsLong(7);
System.out.println("audit (an toàn chỉ khi một luồng): " + audit);
// 4) Hai lambda giống hệt nhau vẫn là hai đối tượng khác nhau (không so sánh equals được)
LongUnaryOperator a = x -> x + 1;
LongUnaryOperator b = x -> x + 1;
System.out.println("a.equals(b) = " + a.equals(b));
}Kết quả (java -cp out Gotchas). Tên lớp lambda phụ thuộc phiên bản JDK: bản chạy ở đây in Gotchas$$Lambda/0x..., còn Java 17 có thể in thêm hậu tố dạng $$Lambda$14/0x...; phần mã băm và địa chỉ đã được lược bớt:
toString lambda: Gotchas$$Lambda/0x...@...
Lớp của lambda là hidden class: true
Giá có VAT 10%: 1100000
audit (an toàn chỉ khi một luồng): [5, 7]
a.equals(b) = false- Không có tên để log:
toString()của lambda chỉ cho tên lớp ẩn kèm mã băm. Nếu cần ghi log "đã áp dụng chiến lược nào", hãy dùng enum, lớp có tên, hoặc bọc strategy cùng một nhãn. - Biến bắt phải effectively final: theo Java Tutorials của Oracle, lambda chỉ truy cập được biến cục bộ final hoặc effectively final. Thử bỏ comment
rate++thì trình biên dịch báo "local variables referenced from a lambda expression must be final or effectively final". Trạng thái cần thay đổi phải nằm trong đối tượng, không phải biến cục bộ. - Trạng thái chia sẻ giữa luồng: lambda bắt một
ArrayListrồi gọi từ nhiều luồng sẽ mất dữ liệu hoặc ném lỗi. Strategy dùng chung nên không có trạng thái, hoặc dùng cấu trúc an toàn đa luồng. - Không so sánh được: hai lambda giống hệt vẫn
equalslàfalse. Đừng dùng strategy làm khoá trong Map hay kiểm tra "đã cấu hình chưa" bằng so sánh đối tượng. - Strategy quá lớn trong lambda: lambda nhiều chục dòng làm mất đi lợi thế gọn của nó. Khi thân hàm dài, tách thành phương thức có tên rồi dùng method reference.
- Chọn strategy ở sai chỗ: trong Strategy pattern, nếu context vẫn có
if (type == EXPRESS)để chọn, bạn mới chuyển chuỗi rẽ nhánh sang chỗ khác. Việc chọn nên nằm ở registry hoặc cấu hình.
Checklist áp dụng Strategy pattern
- Strategy pattern có đáng dùng không: có từ hai biến thể trở lên của cùng một thuật toán, và dự kiến sẽ thêm?
- Strategy interface chỉ có một phương thức chính? Nếu có, đánh dấu
@FunctionalInterface. - Strategy không có trạng thái thì dùng hằng lambda; có trạng thái hoặc phụ thuộc thì dùng lớp.
- Việc chọn strategy nằm ở một chỗ duy nhất (registry, enum, cấu hình), không rải khắp code?
- Đã viết test cho từng strategy riêng và test context với strategy giả?
- Tên strategy có xuất hiện trong log hoặc lưu vào DB không? Nếu có, dùng enum hoặc lớp có tên.
Toàn bộ mã của bài được biên dịch với --release 17 và chạy lại trước khi dán kết quả, trên máy 16 GB RAM.
Bài tiếp theo
Bài kế tiếp trong series là Observer pattern: từ listener tới event bus và Flow API. Nếu Strategy pattern cho phép đổi hành vi, Observer cho phép nhiều đối tượng cùng phản ứng khi một sự kiện xảy ra, và ta sẽ xem JDK hỗ trợ điều đó ra sao.



