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

Explore
MikeDaDev's Blog
in java

design patterns in spring boot

Tung Bui Thanh (TungDaDev)11 mins read

Design pattern spring.webp

Áp dụng design pattern không phải để "khoe kỹ năng" hay làm codebase rườm rà. Mục đích chính của pattern là tối giản: bỏ bớt những đoạn if/else chằng chịt, gọn luồng xử lý và giữ business logic sạch, độc lập.

Spring Boot đi với các pattern này khá tự nhiên nhờ Dependency Injection (DI) và Inversion of Control (IoC): container lo việc tạo object và nối chúng lại, nên nhiều pattern chỉ còn là "khai báo vài bean rồi inject". Dưới đây là các pattern hay dùng nhất, kèm ngữ cảnh áp dụng thường gặp.

# strategy pattern

Cho phép đổi thuật toán/hành vi lúc runtime thông qua một interface chung. Mỗi cách xử lý là một bean riêng; Spring inject List<PaymentStrategy> chứa tất cả bean implement interface đó, nên thêm phương thức thanh toán mới chỉ cần thêm một class, không phải sửa chỗ nào khác.

// 1. Định nghĩa Interface chung
public interface PaymentStrategy {
   PaymentResult process(PaymentRequest request);
   String getType(); // Discriminator để phân loại
}
 
// 2. Các Implementations cụ thể
@Component
public class CreditCardPayment implements PaymentStrategy {
   @Override
   public PaymentResult process(PaymentRequest request) { /* Logic thẻ tín dụng */ }
   @Override
   public String getType() { return "CREDIT_CARD"; }
}
 
@Component
public class BankTransferPayment implements PaymentStrategy {
   @Override
   public PaymentResult process(PaymentRequest request) { /* Logic chuyển khoản */ }
   @Override
   public String getType() { return "BANK_TRANSFER"; }
}
 
// 3. Factory - Nơi Spring tự động gom tất cả các bean implement PaymentStrategy
@Component
@RequiredArgsConstructor
public class PaymentStrategyFactory {
   private final List<PaymentStrategy> strategies;
 
   public PaymentStrategy getStrategy(String type) {
       return strategies.stream()
           .filter(s -> s.getType().equals(type))
           .findFirst()
           .orElseThrow(() -> new UnsupportedPaymentException(type));
   }
}
 
// 4. Lớp Service sử dụng - Hoàn toàn không biết về các implementation chi tiết
@Service
@RequiredArgsConstructor
public class PaymentService {
   private final PaymentStrategyFactory factory;
 
   public PaymentResult pay(String type, PaymentRequest request) {
       return factory.getStrategy(type).process(request);
   }
}

Trường hợp sử dụng: nhiều biến thể của cùng một thao tác, chọn theo giá trị lúc runtime (payment type, export format, notification channel). Nếu số strategy nhiều và gọi liên tục, có thể dựng sẵn Map<String, PaymentStrategy> theo getType() trong constructor để tra cứu thay vì duyệt list mỗi lần.

# template method pattern

Pattern này định nghĩa bộ khung (skeleton) của một thuật toán trong một class trừu tượng, để các lớp con ghi đè từng bước cụ thể mà không đổi cấu trúc chung.

// Abstract base class quy định luồng thực thi chuẩn
public abstract class AbstractDataImporter<T> {
 
   // Đánh dấu final để ngăn subclass phá vỡ luồng chuẩn
   public final ImportResult execute(InputStream input) {
       List<T> records = parse(input);
       List<T> validated = records.stream()
           .filter(this::validate)
           .toList();
       int saved = persist(validated);
       postProcess(validated);
 
       return new ImportResult(records.size(), saved);
   }
 
   // Các bước bắt buộc subclass phải tự định nghĩa
   protected abstract List<T> parse(InputStream input);
   protected abstract boolean validate(T record);
 
   // Các bước có sẵn implementation mặc định (Hook methods)
   // (repository: giả định được truyền vào qua constructor của lớp cha)
   protected int persist(List<T> records) {
       return repository.saveAll(records).size();
   }
 
   protected void postProcess(List<T> records) {
       // Default là không làm gì cả
   }
}
 
// Concrete implementation
@Component
public class CsvUserImporter extends AbstractDataImporter<UserDTO> {
   @Override
   protected List<UserDTO> parse(InputStream input) { /* Logic đọc CSV */ }
 
   @Override
   protected boolean validate(UserDTO user) {
       return user.getEmail() != null && StringUtils.hasText(user.getName());
   }
}

Trường hợp sử dụng: workflow có các bước cố định nhưng mỗi bước làm khác nhau (import/export, sinh báo cáo, pipeline xử lý dữ liệu). Bản thân Spring dùng pattern này rất nhiều: JdbcTemplate, RestTemplate, TransactionTemplate giữ phần khung (mở/đóng connection, xử lý exception, commit/rollback), bạn chỉ truyền phần khác biệt vào qua callback.

# observer pattern (spring events)

Có thể xem đây là event-driven architecture thu nhỏ bên trong một ứng dụng monolith. Publisher không cần biết ai đang nghe, domain logic không bị lẫn các việc phụ.

// Event class
public class OrderCompletedEvent extends ApplicationEvent {
   @Getter
   private final Order order;
 
   public OrderCompletedEvent(Object source, Order order) {
       super(source);
       this.order = order;
   }
}
 
// Publisher - Tập trung vào core business, không quan tâm ai lắng nghe
@Service
@RequiredArgsConstructor
public class OrderService {
   private final ApplicationEventPublisher publisher;
 
   @Transactional
   public Order complete(UUID orderId) {
       Order order = findAndComplete(orderId);
       // Bắn event ra ngoài
       publisher.publishEvent(new OrderCompletedEvent(this, order));
       return order;
   }
}
 
// Listeners - Xử lý các side-effects độc lập
@Component
public class InventoryListener {
   @EventListener
   public void onOrderCompleted(OrderCompletedEvent event) {
       decrementStock(event.getOrder().getItems());
   }
}
 
// Đảm bảo tính nhất quán dữ liệu: Chỉ trigger khi Transaction gốc đã commit thành công
@Component
public class AnalyticsListener {
   @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
   public void onOrderCompleted(OrderCompletedEvent event) {
       trackConversion(event.getOrder());
   }
}

Trường hợp sử dụng: cần xử lý các tác vụ phụ (side effect) sau một hành động chính như gửi email, ghi audit log, cập nhật thống kê mà không muốn làm phình code ở service chính.

Để ý hai listener ở trên chạy khác nhau. @EventListener mặc định chạy đồng bộ, cùng thread và cùng transaction với publisher: listener ném exception thì cả transaction của complete() rollback, hợp với việc như trừ tồn kho vốn phải cùng thành công hoặc cùng thất bại. @TransactionalEventListener(AFTER_COMMIT) chỉ chạy sau khi transaction đã commit, hợp với việc "ngoài lề" như analytics, gửi mail; nhưng nếu publishEvent được gọi ngoài transaction thì listener này sẽ không chạy (trừ khi đặt fallbackExecution = true). Event chỉ nằm trong bộ nhớ, app chết là mất; việc quan trọng thì cần outbox hoặc message broker.

# factory pattern

Factory gom logic khởi tạo các đối tượng phức tạp về một chỗ. Thay vì rải new khắp nơi, ta giao việc đó cho một factory. Trong Spring, chính container là một factory khổng lồ (BeanFactory), nên factory tự viết thường chỉ cần khi object phải tạo theo tham số lúc runtime, hoặc như ví dụ dưới: chọn cả một "họ" implementation theo cấu hình.

// Abstract Factory - Phù hợp cho việc khởi tạo một "họ" các đối tượng liên quan
public interface StorageFactory {
   FileWriter createWriter();
   FileReader createReader();
   FileDeleter createDeleter();
}
 
// Khởi tạo bean dựa trên cấu hình môi trường
@Component
@ConditionalOnProperty(name = "storage.type", havingValue = "s3")
public class S3StorageFactory implements StorageFactory {
   public FileWriter createWriter() { return new S3Writer(); }
   public FileReader createReader() { return new S3Reader(); }
   public FileDeleter createDeleter() { return new S3Deleter(); }
}

# decorator pattern (filters & interceptors)

Decorator cho phép "bọc" thêm hành vi mới quanh một object mà không sửa source code của class gốc. Trong Spring hay gặp hai dạng: filter chain của Servlet (mỗi filter bọc lấy request/response rồi chuyển tiếp) và một bean @Primary bọc lấy implementation gốc. Các annotation như @Transactional, @Cacheable cũng chạy theo ý tưởng này, chỉ khác là Spring tự sinh proxy bọc ngoài cho bạn.

// HTTP Filter chain — mỗi filter decorate request/response
@Component
@Order(1)
public class RequestTracingFilter extends OncePerRequestFilter {
   @Override
   protected void doFilterInternal(HttpServletRequest request,
           HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
       String traceId = extractOrGenerate(request);
       MDC.put("traceId", traceId);
       try {
           chain.doFilter(request, response); // delegate to next
       } finally {
           MDC.clear();
       }
   }
}
 
// Service decorator — add caching/logging/retry around existing service
@Component
@Primary
@RequiredArgsConstructor
public class CachedUserService implements UserService {
   // Spring bỏ qua chính bean này khi tìm UserService để inject (self reference),
   // nên delegate là implementation gốc. Có nhiều implementation khác thì cần thêm @Qualifier.
   private final UserService delegate; // original implementation
   private final Cache cache;
 
   @Override
   public User findById(UUID id) {
       return cache.get(id, () -> delegate.findById(id));
   }
}

Trường hợp sử dụng: Cross-cutting concerns (logging, tracing, caching, rate limiting), filter chains.

# chain of responsibility

Request đi qua một chuỗi các "mắt xích" (handler). Mỗi handler tự quyết định xử lý request hay đẩy tiếp cho mắt xích sau. Thường dùng @Order để quy định thứ tự: khi inject List<ValidationHandler>, Spring đã tự sắp xếp list theo @Order, nên ví dụ dưới sort thêm bằng getOrder() chỉ là cho tường minh, giữ một trong hai cách là đủ. Ví dụ này là biến thể "pipeline" (một executor duyệt list và dừng ở lỗi đầu tiên), khác bản kinh điển là mỗi handler tự giữ tham chiếu tới handler kế tiếp.

// Handler interface
public interface ValidationHandler {
   ValidationResult validate(Document document);
   int getOrder();
}
 
// Các Validator cụ thể
@Component
@Order(1)
public class SizeValidator implements ValidationHandler {
   public ValidationResult validate(Document doc) {
       if (doc.getSize() > MAX_SIZE) return ValidationResult.fail("File quá lớn");
       return ValidationResult.ok();
   }
   public int getOrder() { return 1; }
}
 
@Component
@Order(2)
public class MimeTypeValidator implements ValidationHandler {
   public ValidationResult validate(Document doc) {
       if (!ALLOWED_TYPES.contains(doc.getMimeType())) return ValidationResult.fail("Định dạng không hợp lệ");
       return ValidationResult.ok();
   }
   public int getOrder() { return 2; }
}
 
// Executor quản lý chuỗi
@Component
public class ValidationChain {
   private final List<ValidationHandler> handlers;
 
   public ValidationChain(List<ValidationHandler> handlers) {
       this.handlers = handlers.stream()
           .sorted(Comparator.comparingInt(ValidationHandler::getOrder))
           .toList();
   }
 
   public ValidationResult validate(Document document) {
       for (ValidationHandler handler : handlers) {
           ValidationResult result = handler.validate(document);
           // Dừng chuỗi ngay lập tức nếu có một handler báo lỗi (Short-circuit)
           if (result.isFailed()) return result;
       }
       return ValidationResult.ok();
   }
}

# builder pattern (lombok)

Builder giúp tạo object nhiều field mà vẫn dễ đọc, field nào không set thì lấy giá trị mặc định. Kết hợp @Value của Lombok thì object còn là immutable (bất biến: field final, không có setter), dùng chung giữa nhiều thread mà không lo bị sửa ngang. Lưu ý @Data thì vẫn sinh setter, tức là object mutable.

// Standard builder, immutable
@Value
@Builder
public class SearchCriteria {
   private String keyword;
   @Builder.Default
   private int page = 0;
   @Builder.Default
   private int size = 20;
   private String sortBy;
   private SortDirection direction;
}
 
// Usage
SearchCriteria criteria = SearchCriteria.builder()
   .keyword("report")
   .page(2)
   .sortBy("createdDate")
   .build();
 
// Builder with inheritance (ví dụ này dùng @Data nên vẫn mutable)
@SuperBuilder
public abstract class BaseEntity {
   private UUID id;
   private LocalDateTime createdAt;
}
 
@SuperBuilder
@Data
public class Product extends BaseEntity {
   private String name;
   private BigDecimal price;
}

# specification pattern (dynamic queries)

Kết hợp với JPA, Specification pattern giúp xây các câu query tìm kiếm động (điều kiện nào có thì thêm vào) mà không phải viết cả rừng method findByAAndBAndC hay tự ghép chuỗi JPQL. Mỗi điều kiện là một Specification nhỏ, trả null khi tham số rỗng để bị bỏ qua, rồi ghép lại bằng and/or.

// JPA Specification — composable query predicates
public class ProductSpecifications {
 
   public static Specification<Product> hasName(String name) {
       return (root, query, cb) ->
           name == null ? null : cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
   }
 
   public static Specification<Product> hasCategory(String category) {
       return (root, query, cb) ->
           category == null ? null : cb.equal(root.get("category"), category);
   }
 
   public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
       return (root, query, cb) -> {
           if (min == null && max == null) return null;
           if (min != null && max != null) return cb.between(root.get("price"), min, max);
           if (min != null) return cb.greaterThanOrEqualTo(root.get("price"), min);
           return cb.lessThanOrEqualTo(root.get("price"), max);
       };
   }
}
 
// Usage — compose dynamically
public Page<Product> search(ProductSearchRequest req, Pageable pageable) {
   // Specification.where(...) bị deprecated ở Spring Data JPA 3.5; allOf(...) dùng được từ 3.0 tới 4.x
   Specification<Product> spec = Specification.allOf(
       ProductSpecifications.hasName(req.getName()),
       ProductSpecifications.hasCategory(req.getCategory()),
       ProductSpecifications.priceBetween(req.getMinPrice(), req.getMaxPrice()));
 
   return productRepo.findAll(spec, pageable);
}

# null object pattern

Thay vì viết hàng tá dòng if (obj != null), inject một đối tượng có hành vi mặc định (thường là không làm gì cả). Ví dụ dưới dựa vào việc Spring inject được Map<String, NotificationSender> với key là tên bean.

// Interface
public interface NotificationSender {
   void send(String message, String recipient);
}
 
// Null object — does nothing, safe to call
@Component("noOpNotification")
public class NoOpNotificationSender implements NotificationSender {
   @Override
   public void send(String message, String recipient) {
       // intentionally empty — silent no-op
   }
}
 
// Usage — inject default khi channel không configured
@Service
public class AlertService {
   private final NotificationSender sender;
 
   public AlertService(@Value("${alert.channel:noop}") String channel,
                       Map<String, NotificationSender> senders) {
       this.sender = senders.getOrDefault(channel, senders.get("noOpNotification"));
   }
}

# pattern selection guide

Vấn đềPatternSpring Mechanism
Nhiều variants cùng interfaceStrategyList<Interface> injection
Workflow cố định, steps khácTemplate MethodAbstract class
Decouple side effectsObserverApplicationEvent
Complex object creationFactory@Component + Map
Add behavior without modifyingDecorator@Primary + delegate
Sequential validation/processingChain of ResponsibilityOrdered List<Handler>
Dynamic query compositionSpecificationJPA Specification<T>
Avoid null checksNull ObjectDefault @Component
Immutable object constructionBuilderLombok @Builder

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 😎 👍🏻 🚀 🔥.