Vấn đề

Design Pattern không phải là thứ bạn học xong rồi để đó. Trong hệ thống lớn, chúng là ngôn ngữ chung để mô tả cách giải quyết các vấn đề lặp đi lặp lại — về scalability, maintainability và decoupling.

Bài viết này đi vào ba pattern thuộc nhóm Creational: Singleton, Factory và Builder. Không chỉ giải thích cách implement mà còn chỉ ra tại sao chúng tồn tại, khi nào nên dùng và Những điểm dễ nhầm lẫn khi đi phỏng vấn.

Singleton

Singleton đảm bảo một class chỉ có đúng một instance trong toàn bộ vòng đời ứng dụng, và cung cấp một điểm truy cập global tới instance đó.

Use case điển hình: database connection pool, logger, configuration manager — những thứ tốn tài nguyên để khởi tạo và cần dùng chung toàn ứng dụng.

Eager Initialization

Instance được tạo ngay khi class được load — bất kể có ai dùng hay không.

public class DatabasePool {
    private static final DatabasePool INSTANCE = new DatabasePool();

    private DatabasePool() {}

    public static DatabasePool getInstance() {
        return INSTANCE;
    }
}

Đơn giản, nhưng không lazy — instance luôn được tạo dù ứng dụng có thể không dùng đến. Nếu khởi tạo tốn tài nguyên hoặc phụ thuộc vào config chưa sẵn sàng lúc class loading, đây là vấn đề.

Lazy Initialization

Instance chỉ được tạo khi lần đầu tiên cần dùng.

public class DatabasePool {
    private static DatabasePool instance;

    private DatabasePool() {}

    public static DatabasePool getInstance() {
        if (instance == null) {
            instance = new DatabasePool();
        }
        return instance;
    }
}

Tiết kiệm tài nguyên hơn, nhưng không thread-safe. Trong môi trường multi-thread, hai thread có thể cùng vào if (instance == null) và tạo ra hai instance khác nhau — đây là race condition kinh điển.

Thread-safe Singleton

Cách đơn giản nhất để fix là thêm synchronized vào method:

public class DatabasePool {
    private static DatabasePool instance;

    private DatabasePool() {}

    public static synchronized DatabasePool getInstance() {
        if (instance == null) {
            instance = new DatabasePool();
        }
        return instance;
    }
}

Đã thread-safe, nhưng synchronized được áp dụng cho mọi lần gọi getInstance() — kể cả khi instance đã tồn tại rồi. Điều này tạo ra bottleneck không cần thiết trong hệ thống có nhiều thread gọi đồng thời.

Double-checked Locking

Chỉ synchronized ở lần đầu tiên khởi tạo, không lock những lần gọi sau:

public class DatabasePool {
    private static volatile DatabasePool instance;

    private DatabasePool() {}

    public static DatabasePool getInstance() {
        if (instance == null) {                     // Lần check đầu — không lock
            synchronized (DatabasePool.class) {
                if (instance == null) {             // Lần check thứ hai — trong lock
                    instance = new DatabasePool();
                }
            }
        }
        return instance;
    }
}

volatile đảm bảo mọi thread đều đọc giá trị mới nhất của instance từ main memory, không bị cache ở CPU riêng của từng thread. Không có volatile, một thread có thể thấy instance != null nhưng object bên trong vẫn chưa được khởi tạo xong — đây là vấn đề của Java Memory Model.

Check instance == null lần thứ hai bên trong synchronized là bắt buộc — nếu hai thread cùng vượt qua check đầu tiên, chỉ một thread vào synchronized block trước, thread kia phải đợi. Khi thread thứ hai vào, nếu không check lại, nó sẽ tạo thêm một instance nữa.

Initialization-on-demand Holder

Cách sạch nhất và được ưa dùng nhất trong thực tế:

public class DatabasePool {
    private DatabasePool() {}

    private static class Holder {
        private static final DatabasePool INSTANCE = new DatabasePool();
    }

    public static DatabasePool getInstance() {
        return Holder.INSTANCE;
    }
}

Holder là inner class static — JVM chỉ load class này khi getInstance() được gọi lần đầu. Class loading trong JVM đã được đảm bảo thread-safe, nên không cần synchronized hay volatile. Vừa lazy, vừa thread-safe, vừa không có overhead — đây là lý do pattern này được ưa dùng hơn double-checked locking.

Dùng Singleton trong hệ thống lớn như thế nào?

Vấn đề với Singleton là nó tạo ra global state — khó test, khó mock, khó scale theo chiều horizontal vì instance gắn với một JVM cụ thể. Trong hệ thống microservice hay distributed system, “singleton” thường được thay bằng một service bên ngoài như Redis để chia sẻ state giữa nhiều instance.

Trong Spring, @Bean mặc định là singleton scope — Spring container quản lý lifecycle thay vì bạn tự viết. Vì vậy trong Spring project, gần như không cần tự implement Singleton pattern.

Những điểm dễ nhầm lẫn

“Singleton có vi phạm Single Responsibility Principle không?”

Có. Singleton class vừa chịu trách nhiệm về business logic của nó, vừa chịu trách nhiệm quản lý lifecycle của chính mình. Đây là lý do nhiều người gọi Singleton là anti-pattern trong OOP thuần túy — nhưng vẫn hữu dụng trong thực tế nếu dùng đúng chỗ.

“Singleton có thực sự chỉ có một instance không?”

Không nhất thiết. Có ít nhất ba trường hợp phá vỡ điều này: reflection (gọi constructor private bằng setAccessible(true)), serialization/deserialization (tạo ra instance mới khi deserialize), và multiple classloader (mỗi classloader có static field riêng). Cách an toàn nhất để tránh tất cả là dùng enum:

public enum DatabasePool {
    INSTANCE;

    public Connection getConnection() { ... }
}

enum trong Java đảm bảo thread-safe, chỉ có một instance, và an toàn với reflection lẫn serialization — đây là cách Joshua Bloch khuyến nghị trong Effective Java.

Factory

Factory Pattern tách biệt việc tạo object khỏi việc sử dụng object. Thay vì caller tự new object, caller yêu cầu factory tạo và trả về.

Có hai biến thể phổ biến: Factory MethodAbstract Factory.

Factory Method

Factory Method định nghĩa một interface để tạo object, nhưng để subclass quyết định class nào sẽ được instantiate. Lấy ví dụ hệ thống notification:

public interface Notification {
    void send(String message);
}

public class EmailNotification implements Notification {
    @Override
    public void send(String message) {
        System.out.println("Email: " + message);
    }
}

public class SmsNotification implements Notification {
    @Override
    public void send(String message) {
        System.out.println("SMS: " + message);
    }
}

public class PushNotification implements Notification {
    @Override
    public void send(String message) {
        System.out.println("Push: " + message);
    }
}
public class NotificationFactory {
    public static Notification create(String type) {
        return switch (type) {
            case "EMAIL" -> new EmailNotification();
            case "SMS"   -> new SmsNotification();
            case "PUSH"  -> new PushNotification();
            default -> throw new IllegalArgumentException("Unknown type: " + type);
        };
    }
}
// Caller không biết và không cần biết class cụ thể đằng sau
Notification n = NotificationFactory.create("EMAIL");
n.send("Đơn hàng của bạn đã được xác nhận");

Abstract Factory

Abstract Factory tạo ra một family of related objects. Cùng ví dụ notification — nhưng lần này hệ thống cần hỗ trợ nhiều provider khác nhau (AWS SNS và Twilio), mỗi provider có cách tạo email, SMS, push riêng:

public interface NotificationFactory {
    Notification createEmail();
    Notification createSms();
    Notification createPush();
}

public class AwsNotificationFactory implements NotificationFactory {
    @Override public Notification createEmail() { return new AwsEmailNotification(); }
    @Override public Notification createSms()   { return new AwsSmsNotification(); }
    @Override public Notification createPush()  { return new AwsPushNotification(); }
}

public class TwilioNotificationFactory implements NotificationFactory {
    @Override public Notification createEmail() { return new TwilioEmailNotification(); }
    @Override public Notification createSms()   { return new TwilioSmsNotification(); }
    @Override public Notification createPush()  { return new TwilioPushNotification(); }
}
public class NotificationService {
    private final NotificationFactory factory;

    public NotificationService(NotificationFactory factory) {
        this.factory = factory;
    }

    public void notifyUser(User user, String message) {
        if (user.hasEmail()) factory.createEmail().send(message);
        if (user.hasPhone()) factory.createSms().send(message);
    }
}

// Swap provider chỉ cần đổi factory — không cần đụng NotificationService
NotificationService service = new NotificationService(new AwsNotificationFactory());

Khi cần chuyển từ AWS sang Twilio, chỉ cần đổi factory ở chỗ khởi tạo — toàn bộ NotificationService không cần sửa một dòng nào.

Dùng Factory trong hệ thống lớn như thế nào?

Factory giải quyết bài toán decoupling — caller phụ thuộc vào interface, không phụ thuộc vào implementation cụ thể. Khi thêm một loại mới (ví dụ SlackNotification), chỉ cần thêm class mới và cập nhật factory, không cần sửa code caller.

Trong Spring, ApplicationContext chính là một Abstract Factory — bạn yêu cầu một bean theo type, Spring quyết định implementation nào được trả về tùy theo cấu hình.

Những điểm dễ nhầm lẫn

“Factory Method và Abstract Factory khác nhau như thế nào?”

Factory Method tạo một loại object, để subclass quyết định class cụ thể. Abstract Factory tạo một nhóm object liên quan, đảm bảo các object trong nhóm tương thích với nhau. Nói đơn giản: Factory Method là một method, Abstract Factory là một object chứa nhiều factory method.

“Factory Pattern có vi phạm Open/Closed Principle không?”

Có thể, nếu factory dùng switch — mỗi lần thêm type mới là phải sửa factory. Cách giải quyết là dùng registry pattern: đăng ký các factory function vào một map, thêm type mới chỉ cần đăng ký thêm mà không sửa logic cũ:

public class NotificationFactory {
    private static final Map<String, Supplier<Notification>> registry = new HashMap<>();

    static {
        registry.put("EMAIL", EmailNotification::new);
        registry.put("SMS",   SmsNotification::new);
        registry.put("PUSH",  PushNotification::new);
    }

    public static void register(String type, Supplier<Notification> supplier) {
        registry.put(type, supplier);
    }

    public static Notification create(String type) {
        Supplier<Notification> supplier = registry.get(type);
        if (supplier == null) throw new IllegalArgumentException("Unknown type: " + type);
        return supplier.get();
    }
}

// Thêm Slack mà không sửa factory
NotificationFactory.register("SLACK", SlackNotification::new);

Builder

Builder tách biệt quá trình xây dựng một object phức tạp khỏi representation của nó, cho phép tạo object có nhiều tham số optional một cách rõ ràng và có kiểm soát.

Vấn đề với constructor thông thường khi object có nhiều field:

// Telescoping constructor — khó đọc, dễ nhầm thứ tự tham số
Notification n = new Notification("alice@example.com", "Xác nhận đơn hàng", null, null, true, 5);

Builder giải quyết bằng cách cho phép set từng field theo tên:

public class Notification {
    private final String recipient;   // Bắt buộc
    private final String message;     // Bắt buộc
    private final String subject;     // Optional
    private final String templateId;  // Optional
    private final boolean urgent;     // Optional
    private final int retryCount;     // Optional

    private Notification(Builder builder) {
        this.recipient  = builder.recipient;
        this.message    = builder.message;
        this.subject    = builder.subject;
        this.templateId = builder.templateId;
        this.urgent     = builder.urgent;
        this.retryCount = builder.retryCount;
    }

    public static class Builder {
        private final String recipient;
        private final String message;
        private String subject;
        private String templateId;
        private boolean urgent = false;
        private int retryCount = 3;

        public Builder(String recipient, String message) {
            this.recipient = recipient;
            this.message   = message;
        }

        public Builder subject(String subject)       { this.subject = subject; return this; }
        public Builder templateId(String templateId) { this.templateId = templateId; return this; }
        public Builder urgent(boolean urgent)        { this.urgent = urgent; return this; }
        public Builder retryCount(int retryCount)    { this.retryCount = retryCount; return this; }

        public Notification build() {
            if (recipient == null || recipient.isBlank()) throw new IllegalStateException("recipient is required");
            if (message == null || message.isBlank())     throw new IllegalStateException("message is required");
            return new Notification(this);
        }
    }
}
Notification notification = new Notification.Builder("alice@example.com", "Đơn hàng đã được xác nhận")
    .subject("Xác nhận đơn hàng #1234")
    .urgent(true)
    .retryCount(5)
    .build();

Validation được tập trung trong build() thay vì nằm rải rác ở caller. Nếu build() thành công, object guaranteed là valid — caller không thể nhận được object ở trạng thái thiếu dữ liệu.

Dùng Builder trong hệ thống lớn như thế nào?

Builder thường xuất hiện khi xây dựng query object, request/response DTO, hay configuration object. HttpClient trong Java 11 là ví dụ điển hình từ chính JDK:

HttpClient client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(10))
    .followRedirects(HttpClient.Redirect.NORMAL)
    .build();

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users"))
    .header("Authorization", "Bearer " + token)
    .GET()
    .build();

Builder cũng giúp tạo immutable object — tất cả field đều final, một khi build() được gọi, object không thể bị thay đổi. Điều này quan trọng trong môi trường multi-thread vì immutable object inherently thread-safe.

Trong thực tế, nhiều team dùng Lombok @Builder để tránh viết boilerplate — nhưng hiểu cách Builder hoạt động bên dưới giúp bạn debug và customize khi cần.

Những điểm dễ nhầm lẫn

“Builder và Constructor nên dùng cái nào?”

Constructor phù hợp khi object có ít tham số (dưới 4-5) và tất cả đều bắt buộc. Builder phù hợp khi có nhiều tham số optional, khi cần validation phức tạp trước khi tạo object, hoặc khi muốn tạo immutable object mà không cần constructor khổng lồ.

“Builder có khác gì Setter không?”

Setter cho phép thay đổi object sau khi tạo — object có thể ở trạng thái “chưa hoàn chỉnh” bất kỳ lúc nào. Builder tập trung toàn bộ quá trình xây dựng vào một chỗ, object chỉ được tạo khi build() được gọi và đã qua validation — caller không thể có object ở trạng thái invalid.

“Lombok @Builder có thay thế hoàn toàn Builder tự viết không?”

Gần như vậy với các trường hợp thông thường. Nhưng Lombok @Builder không hỗ trợ validation trong build() out of the box, và khó customize khi cần logic phức tạp hơn — ví dụ validate sự phụ thuộc giữa các field, hoặc tạo immutable object với field final. Trong những trường hợp đó, vẫn cần tự viết.

Tổng kết

Ba pattern này thuộc nhóm Creational — tất cả đều xoay quanh bài toán kiểm soát việc tạo object, nhưng mỗi cái giải quyết một vấn đề khác nhau:

  • Singleton — đảm bảo chỉ có một instance, dùng khi resource sharing là cần thiết. Trong hệ thống hiện đại, thường nhường lại cho DI container như Spring quản lý
  • Factory — tách logic tạo object khỏi caller, dùng khi cần decoupling và extensibility. Abstract Factory mở rộng thêm cho bài toán nhiều provider
  • Builder — kiểm soát quá trình xây dựng object phức tạp, dùng khi có nhiều tham số optional và cần validation tập trung

Trong hệ thống lớn, ba pattern này thường xuất hiện cùng nhau: Factory quyết định loại object cần tạo, Builder xây dựng object đó với đầy đủ tham số, Singleton đảm bảo factory hay pool được dùng chung. Hiểu rõ từng cái — và biết khi nào không nên dùng — mới là điều thực sự quan trọng.