Design patterns are battle-tested solutions to recurring problems in software design. For Java developers, especially those working with Spring Boot, mastering these patterns isn't just about passing technical interviews — it's about writing maintainable, extensible code that stands the test of time.
In this guide, I'll break down essential design patterns with clear, practical Spring Boot implementations that you can immediately apply to your projects. No abstract theory — just concrete examples you can use today.
Why Design Patterns Matter in Modern Java Development
Before diving into specific patterns, let's address why they remain relevant in 2025:
- Communication: Patterns provide a shared vocabulary for developers
- Proven Solutions: They represent decades of collective problem-solving
- Future-Proofing: Well-implemented patterns make code more adaptable to change
- Interview Readiness: Design pattern knowledge is a staple in technical interviews
With Spring Boot's prominence in enterprise development, knowing how these patterns manifest in Spring applications is particularly valuable. Let's explore the most important ones.
1. Singleton Pattern
Problem: Ensure a class has only one instance while providing global access to it.
Spring Boot Implementation:
Spring handles singletons beautifully through its IoC container. By default, all beans in Spring are singletons:
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User findById(Long id) {
return userRepository.findById(id).orElseThrow(() ->
new ResourceNotFoundException("User not found with id: " + id));
}
}When you inject UserService into multiple components, Spring provides the same instance:
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService; // Same singleton instance
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
}Why It's Powerful: Spring's dependency injection ensures singletons are thread-safe and lazily initialized, solving common issues with manual singleton implementations.
2. Factory Method Pattern
Problem: Define an interface for creating objects, but let subclasses decide which classes to instantiate.
Spring Boot Implementation:
Spring Boot's @Configuration classes act as factories. Here's a payment processor factory:
@Configuration
public class PaymentProcessorFactory {
@Bean
@ConditionalOnProperty(name = "payment.gateway", havingValue = "stripe")
public PaymentProcessor stripeProcessor() {
return new StripePaymentProcessor();
}
@Bean
@ConditionalOnProperty(name = "payment.gateway", havingValue = "paypal")
public PaymentProcessor paypalProcessor() {
return new PayPalPaymentProcessor();
}
@Bean
@ConditionalOnMissingBean
public PaymentProcessor defaultProcessor() {
return new DefaultPaymentProcessor();
}
}The client code simply injects the appropriate payment processor:
@Service
public class OrderService {
private final PaymentProcessor paymentProcessor;
public OrderService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
public void processOrder(Order order) {
// Business logic
paymentProcessor.processPayment(order.getAmount());
}
}Why It's Powerful: Spring's conditional bean creation makes factories incredibly dynamic, allowing behavior to change based on configuration without modifying client code.
3. Strategy Pattern
Problem: Define a family of algorithms, encapsulate each one, and make them interchangeable.
Spring Boot Implementation:
Let's implement different notification strategies:
public interface NotificationStrategy {
void sendNotification(User user, String message);
}
@Component
public class EmailNotificationStrategy implements NotificationStrategy {
@Override
public void sendNotification(User user, String message) {
// Email-specific logic
}
}
@Component
public class SMSNotificationStrategy implements NotificationStrategy {
@Override
public void sendNotification(User user, String message) {
// SMS-specific logic
}
}
@Component
public class PushNotificationStrategy implements NotificationStrategy {
@Override
public void sendNotification(User user, String message) {
// Push notification logic
}
}Then we can select a strategy dynamically:
@Service
public class NotificationService {
private final Map<String, NotificationStrategy> strategies;
public NotificationService(List<NotificationStrategy> strategyList) {
strategies = strategyList.stream()
.collect(Collectors.toMap(
strategy -> strategy.getClass().getSimpleName(),
strategy -> strategy
));
}
public void notify(User user, String message, String channel) {
String strategyName = channel + "NotificationStrategy";
NotificationStrategy strategy = strategies.get(strategyName);
if (strategy == null) {
strategy = strategies.get("EmailNotificationStrategy"); // Default
}
strategy.sendNotification(user, message);
}
}Why It's Powerful: This pattern allows you to swap algorithms without changing client code, essential for features like payment processing, validation, or business rule evaluation.
4. Observer Pattern
Problem: Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified.
Spring Boot Implementation:
Spring's event system is a perfect implementation of the Observer pattern:
// The event (what is being observed)
public class OrderCreatedEvent {
private final Order order;
public OrderCreatedEvent(Order order) {
this.order = order;
}
public Order getOrder() {
return order;
}
}
// The publisher (subject)
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public OrderService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public Order createOrder(OrderRequest request) {
Order order = new Order();
// Process order
// Save to database
// Publish event
eventPublisher.publishEvent(new OrderCreatedEvent(order));
return order;
}
}
// Observer 1 - Notification service
@Component
public class NotificationListener {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
Order order = event.getOrder();
// Send confirmation email
}
}
// Observer 2 - Inventory service
@Component
public class InventoryListener {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
Order order = event.getOrder();
// Update inventory
}
}Why It's Powerful: This pattern helps you build loosely coupled systems where new subscribers can be added without modifying the publisher.
5. Builder Pattern
Problem: Construct complex objects step by step, allowing different representations using the same construction process.
Spring Boot Implementation:
Lombok's @Builder annotation provides a clean builder implementation:
@Data
@Builder
public class UserDTO {
private Long id;
private String firstName;
private String lastName;
private String email;
@Builder.Default
private List<String> roles = new ArrayList<>();
private Address address;
private boolean active;
}In your service:
@Service
public class UserMapper {
public UserDTO toDTO(User user) {
return UserDTO.builder()
.id(user.getId())
.firstName(user.getFirstName())
.lastName(user.getLastName())
.email(user.getEmail())
.roles(user.getRoles().stream()
.map(Role::getName)
.collect(Collectors.toList()))
.address(user.getAddress())
.active(user.isActive())
.build();
}
}Why It's Powerful: Builders make object creation readable and maintainable, especially for objects with many optional parameters.
6. Decorator Pattern
Problem: Add responsibilities to objects dynamically without subclassing.
Spring Boot Implementation:
Let's create a cache decorator for a data service:
public interface DataService {
Data fetchData(String key);
}
@Service
@Primary
public class CachingDataServiceDecorator implements DataService {
private final DataService dataService;
private final Cache cache;
public CachingDataServiceDecorator(
@Qualifier("basicDataService") DataService dataService,
Cache cache) {
this.dataService = dataService;
this.cache = cache;
}
@Override
public Data fetchData(String key) {
// Check cache first
Data cachedData = cache.get(key);
if (cachedData != null) {
return cachedData;
}
// If not in cache, delegate to the original service
Data data = dataService.fetchData(key);
// Cache for future requests
cache.put(key, data);
return data;
}
}
@Service("basicDataService")
public class BasicDataService implements DataService {
@Override
public Data fetchData(String key) {
// Expensive operation to fetch data
return new Data(key);
}
}Why It's Powerful: Decorators allow adding behaviors like caching, logging, or security without modifying the original class.
7. Template Method Pattern
Problem: Define the skeleton of an algorithm, deferring some steps to subclasses without changing the algorithm's structure.
Spring Boot Implementation:
Spring's JdbcTemplate is the canonical example, but let's implement our own:
public abstract class FileProcessorTemplate {
// Template method
public final void processFile(String path) {
File file = openFile(path);
String content = readContent(file);
processContent(content);
closeFile(file);
notifyCompletion(path);
}
// Common implementation
protected File openFile(String path) {
System.out.println("Opening file: " + path);
return new File(path);
}
// Common implementation
protected void closeFile(File file) {
System.out.println("Closing file");
}
// Common implementation
protected void notifyCompletion(String path) {
System.out.println("Processing completed for: " + path);
}
// Abstract methods to be implemented by subclasses
protected abstract String readContent(File file);
protected abstract void processContent(String content);
}
@Service
public class CSVFileProcessor extends FileProcessorTemplate {
@Override
protected String readContent(File file) {
// CSV-specific reading logic
return "csv content";
}
@Override
protected void processContent(String content) {
// CSV-specific processing logic
}
}
@Service
public class XMLFileProcessor extends FileProcessorTemplate {
@Override
protected String readContent(File file) {
// XML-specific reading logic
return "xml content";
}
@Override
protected void processContent(String content) {
// XML-specific processing logic
}
}Why It's Powerful: Template methods enforce a consistent process while allowing customization of specific steps.
8. Adapter Pattern
Problem: Convert the interface of a class into another interface clients expect.
Spring Boot Implementation:
Adapting a legacy payment system to a new interface:
// New interface expected by client code
public interface ModernPaymentGateway {
PaymentResponse processPayment(PaymentRequest request);
}
// Legacy system with incompatible interface
public class LegacyPaymentSystem {
public boolean makePayment(String accountId, double amount, String currency) {
// Legacy payment processing
return true;
}
}
// Adapter that makes legacy system compatible with new interface
@Service
public class LegacyPaymentAdapter implements ModernPaymentGateway {
private final LegacyPaymentSystem legacySystem;
public LegacyPaymentAdapter(LegacyPaymentSystem legacySystem) {
this.legacySystem = legacySystem;
}
@Override
public PaymentResponse processPayment(PaymentRequest request) {
boolean success = legacySystem.makePayment(
request.getAccountId(),
request.getAmount(),
request.getCurrency()
);
return new PaymentResponse(
success ? "SUCCESS" : "FAILURE",
UUID.randomUUID().toString(),
LocalDateTime.now()
);
}
}Why It's Powerful: Adapters let you integrate legacy systems or third-party libraries without modifying your core code.
9. Repository Pattern
Problem: Abstract the data layer, centralizing data access logic.
Spring Boot Implementation:
Spring Data JPA implements the repository pattern beautifully:
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
private BigDecimal price;
private Integer stockQuantity;
// Getters, setters, etc.
}
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByPriceGreaterThan(BigDecimal price);
@Query("SELECT p FROM Product p WHERE p.stockQuantity < :threshold")
List<Product> findLowStockProducts(@Param("threshold") Integer threshold);
Optional<Product> findByName(String name);
}Usage:
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<Product> getLowStockProducts() {
return productRepository.findLowStockProducts(10);
}
public Optional<Product> findByName(String name) {
return productRepository.findByName(name);
}
}Why It's Powerful: The repository pattern centralizes data access logic, making your code more testable and allowing you to switch persistence mechanisms with minimal impact.
10. Composite Pattern
Problem: Compose objects into tree structures to represent part-whole hierarchies.
Spring Boot Implementation:
Let's implement a menu system with the composite pattern:
public abstract class MenuComponent {
protected String name;
protected String url;
public MenuComponent(String name, String url) {
this.name = name;
this.url = url;
}
public abstract List<MenuComponent> getChildren();
public abstract boolean isLeaf();
public String getName() {
return name;
}
public String getUrl() {
return url;
}
}
public class MenuItem extends MenuComponent {
public MenuItem(String name, String url) {
super(name, url);
}
@Override
public List<MenuComponent> getChildren() {
return List.of();
}
@Override
public boolean isLeaf() {
return true;
}
}
public class Menu extends MenuComponent {
private List<MenuComponent> children = new ArrayList<>();
public Menu(String name, String url) {
super(name, url);
}
public void add(MenuComponent component) {
children.add(component);
}
public void remove(MenuComponent component) {
children.remove(component);
}
@Override
public List<MenuComponent> getChildren() {
return children;
}
@Override
public boolean isLeaf() {
return false;
}
}Creating a nested menu structure:
@Service
public class MenuService {
public MenuComponent buildApplicationMenu() {
Menu mainMenu = new Menu("Main Menu", "/");
Menu userMenu = new Menu("User Management", "/users");
userMenu.add(new MenuItem("List Users", "/users/list"));
userMenu.add(new MenuItem("Add User", "/users/add"));
Menu reportMenu = new Menu("Reports", "/reports");
reportMenu.add(new MenuItem("Sales Report", "/reports/sales"));
reportMenu.add(new MenuItem("Inventory Report", "/reports/inventory"));
mainMenu.add(userMenu);
mainMenu.add(reportMenu);
mainMenu.add(new MenuItem("Settings", "/settings"));
return mainMenu;
}
}Why It's Powerful: The composite pattern lets you build complex tree structures with a consistent interface for both individual objects and compositions.
Real-World Application: Combining Patterns
Design patterns truly shine when combined. Let's examine how multiple patterns work together in a real-world Spring Boot application:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
private final OrderMapper orderMapper;
public OrderController(OrderService orderService, OrderMapper orderMapper) {
this.orderService = orderService;
this.orderMapper = orderMapper;
}
@PostMapping
public ResponseEntity<OrderDTO> createOrder(@RequestBody OrderRequest request) {
Order order = orderService.createOrder(request);
return ResponseEntity.ok(orderMapper.toDTO(order));
}
}
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentProcessor paymentProcessor;
private final ApplicationEventPublisher eventPublisher;
public OrderService(
OrderRepository orderRepository,
PaymentProcessor paymentProcessor,
ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.paymentProcessor = paymentProcessor;
this.eventPublisher = eventPublisher;
}
@Transactional
public Order createOrder(OrderRequest request) {
// Build order from request
Order order = Order.builder()
.customer(request.getCustomerId())
.items(request.getItems().stream()
.map(item -> new OrderItem(item.getProductId(), item.getQuantity(), item.getPrice()))
.collect(Collectors.toList()))
.status(OrderStatus.PENDING)
.createdAt(LocalDateTime.now())
.build();
// Save to repository
order = orderRepository.save(order);
// Process payment
PaymentResult result = paymentProcessor.processPayment(
new PaymentRequest(order.getId(), order.getTotalAmount()));
if (result.isSuccessful()) {
order.setStatus(OrderStatus.PAID);
order = orderRepository.save(order);
// Publish event
eventPublisher.publishEvent(new OrderCreatedEvent(order));
} else {
order.setStatus(OrderStatus.PAYMENT_FAILED);
order = orderRepository.save(order);
}
return order;
}
}In this example, we're using:
- Builder Pattern: For Order creation
- Repository Pattern: For data access
- Strategy Pattern: With the PaymentProcessor interface
- Observer Pattern: By publishing events
- Singleton Pattern: Implicitly through Spring's default bean scope
Common Pitfalls to Avoid
- Pattern Obsession: Don't force patterns where they don't fit
- Over-engineering: Start simple, introduce patterns as complexity grows
- Ignoring Spring's Built-in Patterns: Spring implements many patterns internally
- Poor Naming: Name implementations clearly (e.g.,
JwtAuthenticationProviderrather thanAuthProviderImpl) - Rigid Patterns: Adapt patterns to fit your specific needs
Interview Tips: Discussing Design Patterns Effectively
When asked about design patterns in interviews:
- Be Specific: Mention concrete implementations you've used
- Focus on Problems Solved: Explain why you chose a pattern, not just how it works
- Discuss Trade-offs: No pattern is perfect for every situation
- Connect to Spring Ecosystem: Show how patterns integrate with Spring's architecture
- Implementation Flexibility: Demonstrate how patterns provide future flexibility
Conclusion
Design patterns aren't academic exercises — they're practical tools for solving real problems in Spring Boot applications. By understanding these patterns deeply and implementing them thoughtfully, you'll write more maintainable, flexible code that stands up to changing requirements.
The key is to view patterns as tools, not rules. Use them when they solve your specific problems, adapt them when necessary, and combine them to create elegant solutions.
Start by identifying the patterns already present in your codebase, then gradually introduce new ones as you refactor and extend your applications. With practice, you'll develop an intuition for when and how to apply each pattern effectively.
What design patterns have you found most helpful in your Spring Boot projects? Share your experiences in the comments below!