Design patterns hay hỏi, giải bằng bài toán của shop-backend
Tiếp đi: design pattern. Phỏng vấn hay hỏi những pattern nào?
Phỏng vấn ít khi bắt vẽ UML. Họ hỏi "pattern này giải quyết vấn đề gì, em đã dùng chưa". Nên mỗi pattern dưới đây đi kèm một vấn đề thật trong shop-backend.
1. Bài toán: OrderService làm mọi thứ
public PaymentResult checkout(Order order, String method) {
if (method.equals("MOMO")) { ... } else if (method.equals("VNPAY")) { ... } else if ...
double discount = order.getCustomer().isVip() ? 0.1 : (isBlackFriday() ? 0.2 : 0);
emailService.send(...); smsService.send(...); loyaltyService.addPoints(...);
// gọi SDK cũ của đơn vị vận chuyển với API rất khác
}
2. Vấn đề
- Thêm cổng thanh toán, thêm khuyến mãi, thêm việc sau khi đặt hàng: đều phải sửa method này (vi phạm Open/Closed).
- Test một nhánh phải dựng cả đống dependency.
3. Giải pháp: 3 nhóm pattern
Creational (tạo object) : Singleton, Factory, Builder
Structural (ghép object) : Adapter, Decorator, Proxy
Behavioral (chia hành vi) : Strategy, Observer, Template Method
4. Code từng bước
Bước 1: Strategy, mỗi khuyến mãi một class
public interface DiscountPolicy { BigDecimal apply(Order order); }
@Component class VipDiscount implements DiscountPolicy { ... }
@Component class BlackFridayDiscount implements DiscountPolicy { ... }
BigDecimal discount = policies.stream().map(p -> p.apply(order)).max(naturalOrder()).orElse(ZERO);
Bước 2: Factory, chọn cổng thanh toán theo tên
@Component
public class PaymentGatewayFactory {
private final Map<String, PaymentGateway> byType;
public PaymentGatewayFactory(List<PaymentGateway> gateways) {
byType = gateways.stream().collect(toMap(PaymentGateway::type, g -> g));
}
public PaymentGateway get(String type) {
return Optional.ofNullable(byType.get(type))
.orElseThrow(() -> new IllegalArgumentException("Unsupported: " + type));
}
}
Bước 3: Builder, tạo Order nhiều field tùy chọn
Order order = Order.builder()
.customerId("C-1")
.line("SKU-1", 2)
.voucher("SALE10") // tùy chọn
.note("Giao giờ hành chính") // tùy chọn
.build(); // build() validate một chỗ
Thay cho constructor 8 tham số dễ truyền nhầm thứ tự. Lombok @Builder sinh sẵn.
Bước 4: Observer, các việc sau khi đặt hàng
public record OrderPlaced(String orderId) {}
publisher.publishEvent(new OrderPlaced(order.getId())); // OrderService không biết ai nghe
@EventListener void sendEmail(OrderPlaced e) { ... } // thêm listener mới không sửa OrderService
Chi tiết cách Spring làm xem Events, scheduling, @Async.
Bước 5: Adapter, bọc SDK vận chuyển cũ
public interface ShippingProvider { String createShipment(Order o); }
public class LegacyGhnAdapter implements ShippingProvider {
private final GhnClientV1 sdk; // API cũ: createWaybill(Map<String,String>)
public String createShipment(Order o) {
Map<String, String> req = Map.of("ma_don", o.getId(), "dia_chi", o.getAddress());
return sdk.createWaybill(req).getCode(); // đổi interface này sang interface kia
}
}
Bước 6: Decorator, thêm retry + log quanh gateway
public class RetryingGateway implements PaymentGateway {
private final PaymentGateway inner; // cùng interface, bọc object khác
public PaymentResult charge(Order o) {
for (int i = 1; ; i++) {
try { return inner.charge(o); }
catch (PaymentGatewayException e) { if (i == 3) throw e; }
}
}
}
PaymentGateway g = new LoggingGateway(new RetryingGateway(new MomoGateway()));
Bước 7: Template Method và Proxy
- Template Method:
AbstractPayment.pay()final, con chỉ viếtdoPay(). Đã có ở Interface vs abstract class. - Proxy: object đứng thay object thật để kiểm soát truy cập (lazy load, transaction, cache).
@Transactionallà ví dụ nổi tiếng nhất, xem bài sau.
Bước 8: Singleton
public enum IdGenerator { // cách an toàn nhất: enum
INSTANCE;
private final AtomicLong seq = new AtomicLong();
public long next() { return seq.incrementAndGet(); }
}
5. Cách hoạt động: Decorator vs Proxy vs Adapter
Adapter: Client ──ShippingProvider──▶ Adapter ──API khác──▶ GhnClientV1
Decorator: Client ──Gateway──▶ Logging ──Gateway──▶ Retry ──Gateway──▶ Momo
Proxy: Client ──Service──▶ Proxy(begin tx) ──Service──▶ Service thật (commit/rollback)
6. So sánh
| Pattern | Vấn đề trong shop-backend | Ý chính |
|---|---|---|
| Singleton | Một IdGenerator cho cả app | Đúng 1 instance |
| Factory | Chọn gateway theo "MOMO" | Giấu logic tạo/chọn object |
| Builder | Order nhiều field tùy chọn | Tạo từng bước, validate cuối |
| Strategy | Nhiều kiểu khuyến mãi | Đổi thuật toán qua interface |
| Observer | Email, SMS, điểm sau đặt hàng | Phát sự kiện, ai cần thì nghe |
| Adapter | SDK vận chuyển khác interface | Đổi interface |
| Decorator | Retry, log quanh gateway | Thêm hành vi, giữ interface |
| Template Method | Khung pay() chung | Khung cố định ở cha, bước ở con |
| Proxy | Transaction quanh service | Đứng thay, kiểm soát truy cập |
7. Khi nào nên dùng / không nên dùng
- Nên: khi đã thấy điểm thay đổi thật (cổng thứ 3, khuyến mãi thứ 3).
- Không nên: dựng Factory + Strategy cho thứ chỉ có 1 cài đặt và không có kế hoạch thêm.
if2 nhánh dễ đọc hơn 4 class. - Không nên: Singleton tự viết trong ứng dụng Spring; để Spring quản lý bean.
Singleton tự viết với getInstance() có vấn đề gì? Mình thấy nhiều bài viết gọi nó là anti-pattern.
Đúng rồi, Singleton "cổ điển" có mấy vấn đề:
public class PaymentConfig {
private static volatile PaymentConfig instance;
private PaymentConfig() {}
public static PaymentConfig getInstance() { // double-checked locking
if (instance == null) {
synchronized (PaymentConfig.class) {
if (instance == null) instance = new PaymentConfig();
}
}
return instance;
}
}
- Dễ viết sai thread-safety (quên
volatile). Reflection và serialization còn tạo được bản thứ hai (enum thì không). - Code gọi
PaymentConfig.getInstance()là phụ thuộc cứng: test không thay được bằng mock. - State global ẩn, khó biết ai sửa.
Spring giải quyết bằng singleton scope: container tạo 1 instance và inject vào nơi cần. Vẫn chỉ 1 instance, nhưng class là class thường, test thì truyền mock qua constructor. Lưu ý: singleton của Spring là "1 bean trong 1 container", không phải 1 trong cả JVM. Chi tiết scope xem Bean scope & lifecycle.
Decorator với Proxy code gần như y hệt: cùng implement interface, bọc object bên trong. Khác nhau ở đâu?
Bạn nhìn đúng: về cấu trúc thì gần như giống nhau. Khác ở mục đích và ai lắp ghép:
| Decorator | Proxy | |
|---|---|---|
| Mục đích | Thêm tính năng (retry, log, nén) | Kiểm soát truy cập (lazy, transaction, security, remote) |
| Ai tạo | Client tự xếp lớp, xếp nhiều lớp tùy ý | Framework tạo, client thường không biết |
| Ví dụ JDK/Spring | BufferedInputStream(new FileInputStream) | @Transactional, Hibernate lazy proxy |
Tương tự Strategy và State: Strategy do client chọn từ ngoài; State thì object tự đổi trạng thái bên trong (ví dụ Order chuyển NEW → PAID → SHIPPED, mỗi state cho phép hành động khác). Phỏng vấn chấp nhận câu trả lời "cấu trúc giống, ý định khác".
Phỏng vấn hỏi "em đã dùng design pattern nào trong dự án?" thì trả lời sao?
Câu 1: Em đã dùng pattern nào?
🇻🇳 Tiếng Việt:
Trong hệ thống bán hàng, em dùng Strategy cho các chính sách giảm giá, mỗi chính sách một bean implement
DiscountPolicy; Factory để chọn cổng thanh toán theo loại, Spring injectList<PaymentGateway>rồi em gom thành Map; Builder để tạo Order; Observer quaApplicationEventđể gửi email, cộng điểm sau khi đặt hàng. Nhờ vậy thêm cổng hay khuyến mãi mới không phải sửa OrderService.
🇬🇧 English:
In an e-commerce backend I used Strategy for discount policies, each one a bean implementing
DiscountPolicy; a Factory to pick the payment gateway by type, built from an injectedList<PaymentGateway>; a Builder for Order; and Observer throughApplicationEventfor emails and loyalty points after an order is placed. Adding a new gateway or promotion didn't require touching OrderService.
Câu 2: Singleton có nhược điểm gì? Viết thread-safe thế nào?
🇻🇳 Tiếng Việt:
Singleton tự viết tạo phụ thuộc cứng, state global, khó test. Thread-safe thì dùng enum, holder class, hoặc double-checked locking với
volatile. Trong Spring em để container quản lý singleton bean và inject qua constructor.
🇬🇧 English:
A hand-written singleton creates hard dependencies and global state and is hard to test. For thread safety use an enum, a holder class, or double-checked locking with
volatile. In Spring I let the container manage singleton beans and inject them through constructors.
Câu 3: Khi nào không nên dùng pattern?
🇻🇳 Tiếng Việt:
Khi chưa có điểm thay đổi thật. Pattern thêm class và tầng gián tiếp; nếu chỉ có một cài đặt thì code đơn giản dễ đọc hơn. Em thêm pattern khi xuất hiện biến thể thứ hai hoặc thứ ba.
🇬🇧 English:
When there is no real variation yet. Patterns add classes and indirection; with only one implementation, plain code is easier to read. I introduce a pattern when the second or third variant shows up.
Cách nhớ ngắn gọn
- Tạo: Singleton, Factory, Builder. Ghép: Adapter, Decorator, Proxy. Hành vi: Strategy, Observer, Template.
- Adapter đổi interface; Decorator thêm tính năng; Proxy kiểm soát truy cập.
- Trả lời bằng vấn đề thật, không bằng định nghĩa sách.
- Không có biến thể thứ hai thì chưa cần pattern.
Tự kiểm tra
Thêm cổng ZaloPay vào Factory dùng List<PaymentGateway> cần sửa gì?
Chỉ thêm class @Component ZaloPayGateway; Factory tự nhận qua list inject.
Vì sao enum là cách viết Singleton an toàn nhất?
JVM đảm bảo tạo một lần, thread-safe, chống reflection và serialization tạo bản thứ hai.
Builder giải quyết vấn đề gì của constructor?
Constructor nhiều tham số (nhất là tùy chọn) dễ truyền nhầm thứ tự; builder đặt tên từng field và validate ở build().
BufferedInputStream là pattern gì?
Decorator: bọc một InputStream khác, cùng interface, thêm buffer.
Adapter khác Decorator thế nào?
Adapter đổi interface này sang interface khác; Decorator giữ nguyên interface, thêm hành vi.