Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

factory pattern

Tung Bui Thanh (TungDaDev)10 mins read

Factory pattern.webp

Thử mở code dự án bạn đang làm xem: có class PaymentFactory hay NotificationFactory nào chứa một khối switch-case hay chuỗi if-else if dài cả 200 dòng không? Mỗi lần thêm một cổng thanh toán mới (VNPay, MoMo, ZaloPay, Stripe) là lại có người nhảy vào sửa class Factory đó, và lại có nguy cơ làm hỏng mấy case cũ. Trớ trêu ở chỗ: Factory Pattern sinh ra để giúp code tuân thủ Open/Closed (mở để mở rộng, đóng để sửa đổi), nhưng viết kiểu này thì chính nó lại phá OCP nặng nhất!

Ở hệ thống enterprise lớn, việc tạo object không thể mãi dựa vào mấy cấu trúc rẽ nhánh viết cứng. Mình cần kiểu dynamic factory: tự phát hiện implementation (auto-discovery), tự đăng ký (self-registration), thêm bao nhiêu loại cũng được mà không phải sửa dòng code cũ nào.

Bài này đi từ Factory GoF cổ điển tới Dynamic Factory dựa trên Spring, rồi tới Functional Factory trong Java hiện đại.


# cái bẫy của Factory kinh điển (the ocp violation trap)

Đây là cách viết Factory mà gần như sách nhập môn hay tutorial nào cũng dạy:

// CÁI BẪY ANTI-PATTERN: Vi phạm nghiêm trọng Open/Closed Principle!
public class HardcodedPaymentFactory {
    public PaymentGateway createGateway(PaymentType type) {
        return switch (type) {
            case VNPAY -> new VnPayGateway();
            case MOMO -> new MoMoGateway();
            case STRIPE -> new StripeGateway();
            // Thêm cổng thanh toán mới -> BẮT BUỘC PHẢI SỬA CLASS NÀY!
            default -> throw new UnsupportedOperationException("Không hỗ trợ");
        };
    }
}

# sao lại gọi là thiết kế tồi?

  1. Phá OCP: Muốn mở rộng thì buộc phải sửa code đang chạy ổn.
  2. Dính chặt vào việc khởi tạo: Nếu VnPayGateway cần inject mấy dependency phức tạp (HttpClient, AuditService, SecretKeyProvider), class Factory sẽ thành một "bãi rác dependency".

Công bằng mà nói, switch trên enum cũng có một cái lợi: nếu bỏ nhánh default đi, thêm hằng số mới vào enum là compiler báo ngay chỗ nào chưa xử lý. Với vài loại ít khi đổi thì cách này vẫn ổn. Nó chỉ thành vấn đề khi danh sách cứ dài ra và mỗi nhánh kéo theo một đống dependency.


# cách làm trong Spring: dynamic Factory (map injection)

Với Spring Boot, mình bỏ hẳn được khối switch-case nhờ chuyện Spring IoC Container tự inject cả List hoặc Map bean.

# cách triển khai

# interface có kèm định danh
public interface PaymentGateway {
    PaymentResult process(PaymentRequest request);
    PaymentType getSupportedType(); // Trả về Enum hoặc String định danh
}
# mỗi gateway một class riêng
@Component("VNPAY")
public class VnPayGateway implements PaymentGateway {
    @Override
    public PaymentResult process(PaymentRequest request) { ... }
 
    @Override
    public PaymentType getSupportedType() { return PaymentType.VNPAY; }
}
 
@Component("MOMO")
public class MoMoGateway implements PaymentGateway {
    @Override
    public PaymentResult process(PaymentRequest request) { ... }
 
    @Override
    public PaymentType getSupportedType() { return PaymentType.MOMO; }
}
# dynamic Factory tự nối (không switch-case!)
@Service
public class EnterprisePaymentFactory {
 
    private final Map<PaymentType, PaymentGateway> gatewayMap;
 
    // Spring tự động thu gom toàn bộ các Bean implements PaymentGateway và truyền vào List
    public EnterprisePaymentFactory(List<PaymentGateway> gateways) {
        this.gatewayMap = gateways.stream()
            .collect(Collectors.toUnmodifiableMap(
                PaymentGateway::getSupportedType,
                Function.identity()
            ));
    }
 
    public PaymentGateway getGateway(PaymentType type) {
        PaymentGateway gateway = gatewayMap.get(type);
        if (gateway == null) {
            throw new UnsupportedPaymentException("Không tìm thấy bộ xử lý cho cổng: " + type);
        }
        return gateway;
    }
}

OCP đúng nghĩa: Khi cần thêm ZaloPay, dev chỉ việc tạo file mới ZaloPayGateway.java gắn @Component("ZALOPAY"). Lúc khởi động, hệ thống tự phát hiện và nạp nó vào Factory. Factory không phải sửa dòng nào. (Vì key ở đây là enum PaymentType, bạn vẫn phải thêm một hằng ZALOPAY vào enum. Muốn tuyệt đối không đụng file cũ thì dùng key kiểu String.)

Vài chi tiết đáng để ý trong cách làm này:

  • Collectors.toUnmodifiableMap ném IllegalStateException nếu hai bean trả về cùng một PaymentType, nên lỡ copy-paste quên đổi type thì app chết ngay lúc khởi động, thay vì âm thầm chọn nhầm gateway lúc chạy. Fail sớm như vậy là tốt.
  • Spring cũng inject thẳng được Map<String, PaymentGateway>, với key là tên bean (chính là chuỗi trong @Component("VNPAY")). Gọn hơn, nhưng định danh khi đó nằm trong annotation chứ không nằm trong code, dễ gõ sai mà compiler không bắt được.
  • Bean mặc định là singleton: mọi request dùng chung một instance VnPayGateway, nên gateway phải stateless hoặc thread-safe. Nếu thật sự cần object mới cho mỗi lần gọi, dùng scope prototype kèm ObjectProvider<T>.

# functional Factory: dùng Java Supplier (không cần Spring)

Nếu bạn đang viết thư viện Java thuần (core SDK), không có Spring, thì làm sao có một Factory vừa linh hoạt vừa không phải tạo sẵn object từ đầu?

Dùng Map<K, Supplier<V>> kết hợp method reference:

public class FunctionalDocumentFactory {
 
    private final Map<String, Supplier<DocumentParser>> registry = new ConcurrentHashMap<>();
 
    public FunctionalDocumentFactory() {
        // Đăng ký lazy constructor thông qua Method Reference
        register("PDF", PdfDocumentParser::new);
        register("DOCX", DocxDocumentParser::new);
        register("EXCEL", ExcelDocumentParser::new);
    }
 
    public void register(String format, Supplier<DocumentParser> supplier) {
        registry.put(format.toUpperCase(Locale.ROOT), Objects.requireNonNull(supplier));
    }
 
    public DocumentParser createParser(String format) {
        Supplier<DocumentParser> supplier = registry.get(format.toUpperCase(Locale.ROOT));
        if (supplier == null) {
            throw new IllegalArgumentException("Định dạng tài liệu không hỗ trợ: " + format);
        }
        return supplier.get(); // Tạo instance mới chỉ khi được yêu cầu!
    }
}

Locale.ROOT ở đây không thừa: toUpperCase() không tham số dùng locale mặc định của máy, và trên máy đặt locale tiếng Thổ Nhĩ Kỳ thì "pdf" vẫn ổn nhưng chữ i sẽ thành İ (có chấm), khiến những key như "wiki" không bao giờ khớp.


# phân biệt Factory method và Abstract Factory

Hai cái này nhiều người hay nhầm, và còn hay nhầm cả với "Simple Factory" (một class có switch như phần đầu bài, vốn không nằm trong 23 pattern của GoF).

Factory Method đúng nghĩa GoF là một method trừu tượng ở lớp cha, để lớp con quyết định tạo object cụ thể nào. Ví dụ quen thuộc nhất là Iterable.iterator(): ArrayList trả về iterator của nó, HashSet trả về iterator khác, code gọi chỉ biết Iterator. Viết theo kiểu ứng dụng thì trông như sau:

public abstract class NotificationService {
    // Factory Method: lớp con quyết định tạo kênh gửi nào
    protected abstract NotificationChannel createChannel();
 
    public void notify(User user, String message) {
        createChannel().send(user, message);
    }
}
 
public class SmsNotificationService extends NotificationService {
    @Override
    protected NotificationChannel createChannel() { return new SmsChannel(); }
}

Ranh giới giữa chúng nhìn từ góc độ kiến trúc như sau:

# khi nào dùng Abstract Factory cho multi-cloud?

Ví dụ hệ thống phải chạy được trên cả AWS lẫn Google Cloud:

  • Bạn không muốn code nghiệp vụ dính chặt vào AWS S3 hay Google Cloud Storage.
  • Bạn định nghĩa CloudFactory có các method: createBlobStorage(), createMessageQueue(), createDatabase().
  • Deploy lên AWS thì nạp AwsCloudFactory → blob, queue, DB đều là đồ của AWS, không có chuyện lẫn S3 của AWS với Pub/Sub của GCP!

# so sánh các biến thể Factory

PatternCách hoạt độngMức tuân thủ OCPĐộ phức tạpDùng khi nào?
Simple Factory (Switch-Case)Một class duy nhất chứa hết logic rẽ nhánh❌ Rất kémThấpApp đồ chơi, số type cố định mãi mãi (vd: các thứ trong tuần).
Spring Dynamic FactoryTự gom bean qua List<T> hoặc Map<String, T>🟢 Tuyệt đốiThấp (nhờ Spring)Theo mình nên là lựa chọn mặc định trong dự án Spring Boot.
Functional Factory (Supplier)Đăng ký lambda vào một registry map🟢 Tuyệt đốiTrung bìnhCore library, SDK độc lập không phụ thuộc Spring.
Abstract Factory (GoF)Tạo một họ object phụ thuộc lẫn nhau🟢 Rất caoCaoHệ thống multi-cloud, GUI đa nền tảng, hỗ trợ nhiều loại DB.

# checklist trước khi lên production (architect's checklist)

  • 1. Bỏ switch-case viết cứng: Rà hết các class Factory, thay chuỗi if-else / switch bằng Spring Map Injection hoặc Functional Registry.
  • 2. Mỗi component một định danh riêng: Bean nào implement interface cũng phải trả về một identifier duy nhất và không đổi.
  • 3. Xử lý khi không tìm thấy (fallback): Factory nên ném custom business exception có ngữ cảnh rõ ràng (ví dụ UnsupportedProviderException), đừng lặng lẽ trả về null.
  • 4. Đăng ký động lúc runtime: Nếu hệ thống có nạp plugin động (dynamic plugin loading), registry map phải dùng ConcurrentHashMap để thread-safe khi đăng ký thêm module.
  • 5. Theo dõi metric: Thêm metric đếm số lần gọi từng provider (ví dụ payment.gateway.invocations{provider="VNPAY"}) để biết traffic thực tế.

# lời kết

Factory Pattern làm đúng thì không phải một mớ câu lệnh điều kiện. Nó là cách gói gọn chuyện khởi tạo object lại một chỗ, để hệ thống thêm loại mới thoải mái mà code đang chạy vẫn nằm yên không ai đụng tới.

Nếu dự án đang dùng Spring, thử rà xem có Factory nào còn switch dài không, rồi chuyển sang kiểu gom bean như trên. Còn nếu chỉ có ba bốn loại cố định thì cứ để switch, đừng dựng kiến trúc cho một bài toán chưa tồn tại.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.