Introduction
Design patterns provide proven solutions for organizing code, reducing complexity, and keeping projects adaptable. In this topic, you’ll explore three powerful behavioral patterns—Strategy, Chain of Responsibility, and Observer—each addressing a different design challenge. Through concise examples and clear explanations, you’ll discover how to simplify your logic, make testing easier, and maintain flexibility as your application evolves.
Strategy
The Strategy pattern streamlines your code by allowing you to choose between multiple algorithms at runtime. Each algorithm is encapsulated in its own class, and your application selects the appropriate one when needed. This design promotes the Open/Closed Principle and keeps your logic easily extendable.
Picture an online bookstore where customers have different membership tiers, such as Regular or Premium. Each tier grants a distinct discount on purchases. You want a solution that makes it easy to add or adjust discount logic without rewriting large parts of the system. Using the Strategy pattern, each discount type becomes a Spring-managed bean, making it easier to extend and maintain.
A Book class represents the product being purchased, while a Customer class holds information about the buyer, including their membership type. The membership type is represented using an enum to differentiate between different tiers.
data class Book(val title: String, val price: Double)
enum class MembershipType {
REGULAR,
PREMIUM
}
data class Customer(val name: String, val membershipType: MembershipType)record Book(String title, double price) {
// empty body
}
enum MembershipType {
REGULAR,
PREMIUM
}
record Customer(String name, MembershipType membershipType) {
// empty body
}With these simple classes in place, the system has a clear way to identify the book’s price and determine the customer’s membership tier, which decides the discount logic.
The DiscountStrategy interface defines the contract for all discount implementations. Each discount strategy must implement a calculateDiscount method that takes a Book and returns the discount amount. Additionally, each strategy must define the getType method to specify which membership tier it applies to.
interface DiscountStrategy {
fun calculateDiscount(book: Book): Double
fun getType(): MembershipType
}public interface DiscountStrategy {
double calculateDiscount(Book book);
MembershipType getType();
}Each discount strategy is now a Spring-managed bean using the @Component annotation. The RegularCustomerDiscount class applies a 10% discount, while the PremiumCustomerDiscount class applies a 20% discount.
import org.springframework.stereotype.Component
@Component
class RegularCustomerDiscount : DiscountStrategy {
override fun calculateDiscount(book: Book): Double {
return book.price * 0.10
}
override fun getType(): MembershipType = MembershipType.REGULAR
}
@Component
class PremiumCustomerDiscount : DiscountStrategy {
override fun calculateDiscount(book: Book): Double {
return book.price * 0.20
}
override fun getType(): MembershipType = MembershipType.PREMIUM
}import org.springframework.stereotype.Component;
@Component
class RegularCustomerDiscount implements DiscountStrategy {
@Override
public double calculateDiscount(Book book) {
return book.price() * 0.10;
}
@Override
public MembershipType getType() {
return MembershipType.REGULAR;
}
}
@Component
class PremiumCustomerDiscount implements DiscountStrategy {
@Override
public double calculateDiscount(Book book) {
return book.price() * 0.20;
}
@Override
public MembershipType getType() {
return MembershipType.PREMIUM;
}
}By annotating each strategy with @Component, Spring automatically registers them as beans, allowing the application to dynamically fetch the appropriate strategy.
Instead of manually selecting a strategy using conditional logic, a configuration class collects all available strategies and maps them by their membership type. This ensures that new strategies can be added without modifying existing logic.
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class DiscountStrategyConfig(private val strategies: List<DiscountStrategy>) {
@Bean
fun discountStrategies(): Map<MembershipType, DiscountStrategy> {
return strategies.associateBy { it.getType() }
}
}import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DiscountStrategyConfig {
private final List<DiscountStrategy> strategies;
public DiscountStrategyConfig(List<DiscountStrategy> strategies) {
this.strategies = strategies;
}
@Bean
public Map<MembershipType, DiscountStrategy> discountStrategies() {
Map<MembershipType, DiscountStrategy> discountStrategies = new HashMap<>();
for (DiscountStrategy strategy : strategies) {
discountStrategies.put(strategy.getType(), strategy);
}
return discountStrategies;
}
}With this configuration, all discount strategies are automatically registered in a Map<MembershipType, DiscountStrategy>. This allows the system to dynamically select the appropriate strategy without relying on hardcoded conditions.
The DiscountCalculatorService retrieves the correct discount strategy from the Map based on the customer’s membership type. Since all strategies are managed by Spring, new strategies can be introduced without modifying this class.
import org.springframework.stereotype.Service
@Service
class DiscountCalculatorService(
private val discountStrategies: Map<MembershipType, DiscountStrategy>
) {
fun calculate(book: Book, customer: Customer): Double {
val strategy = discountStrategies[customer.membershipType]
?: throw IllegalArgumentException("No discount strategy found for ${customer.membershipType}")
return strategy.calculateDiscount(book)
}
}import org.springframework.stereotype.Service;
@Service
public class DiscountCalculatorService {
private final Map<MembershipType, DiscountStrategy> discountStrategies;
public DiscountCalculatorService(Map<MembershipType, DiscountStrategy> discountStrategies) {
this.discountStrategies = discountStrategies;
}
public double calculate(Book book, Customer customer) {
DiscountStrategy strategy = discountStrategies.get(customer.membershipType());
if (strategy == null) {
throw new IllegalArgumentException(
"No discount strategy found for " + customer.membershipType()
);
}
return strategy.calculateDiscount(book);
}
}Spring injects the strategy map into the service, allowing it to retrieve the correct strategy dynamically. If a new membership type, such as Gold, is introduced, the only required change is to add a new @Component-annotated class implementing DiscountStrategy. The system remains open for extension but closed for modification.
To validate the implementation, a test case verifies that the correct discounts are applied for different membership types.
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import kotlin.test.assertEquals
@SpringBootTest
class DiscountCalculatorServiceTest {
@Autowired
private lateinit var discountCalculatorService: DiscountCalculatorService
@Test
fun `test discount calculations`() {
val book = Book("Design Patterns in Kotlin", 50.0)
val regularCustomer = Customer("Alice", MembershipType.REGULAR)
val premiumCustomer = Customer("Bob", MembershipType.PREMIUM)
assertEquals(5.0, discountCalculatorService.calculate(book, regularCustomer))
assertEquals(10.0, discountCalculatorService.calculate(book, premiumCustomer))
}
}import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest
public class DiscountCalculatorServiceTest {
@Autowired
private DiscountCalculatorService discountCalculatorService;
@Test
public void testDiscountCalculations() {
Book book = new Book("Design Patterns in Java", 50.0);
Customer regularCustomer = new Customer("Alice", MembershipType.REGULAR);
Customer premiumCustomer = new Customer("Bob", MembershipType.PREMIUM);
assertEquals(5.0, discountCalculatorService.calculate(book, regularCustomer));
assertEquals(10.0, discountCalculatorService.calculate(book, premiumCustomer));
}
}By leveraging Spring Boot’s dependency injection and component scanning, the Strategy pattern is transformed into a fully dynamic and easily extendable architecture. There is no need for conditional logic or hardcoded strategy selection. Instead, Spring Boot handles the lifecycle and management of strategies, ensuring a scalable and maintainable solution.
Chain of Responsibility
The Chain of Responsibility design pattern allows you to process a request through multiple handlers, where each handler is responsible for a single, well-defined task. Instead of explicitly linking handlers together, this implementation leverages Spring Boot’s dependency injection to automatically collect and execute all handlers in a sequential order. This approach removes the need for manual chaining and makes it easy to add or remove handlers without modifying existing code.
The TextDocument class represents the entity being processed. It contains a content field that stores the text to be modified.
data class TextDocument(
var content: String
)public class TextDocument {
private String content;
public TextDocument(String content) {
this.content = content;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}Each handler implements the TextProcessor interface, ensuring all handlers follow the same contract. This allows Spring Boot to dynamically inject and execute them in sequence.
interface TextProcessor {
fun process(document: TextDocument)
}public interface TextProcessor {
void process(TextDocument document);
}Each handler is responsible for a specific step in the processing pipeline. These handlers are annotated with @Component, making them Spring-managed beans that are automatically discovered and injected.
import org.springframework.stereotype.Component
@Component
class TrimProcessor : TextProcessor {
override fun process(document: TextDocument) {
println("[TrimProcessor] Trimming text: ${document.content}")
document.content = document.content.trim()
}
}
@Component
class CapitalizeProcessor : TextProcessor {
override fun process(document: TextDocument) {
if (document.content.isNotEmpty()) {
println("[CapitalizeProcessor] Capitalizing first letter.")
document.content = document.content.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
}
}
}
@Component
class RemoveExtraSpacesProcessor : TextProcessor {
override fun process(document: TextDocument) {
println("[RemoveExtraSpacesProcessor] Removing extra spaces.")
// Replace multiple whitespace characters with a single space
document.content = document.content.replace(Regex("\\s+"), " ")
}
}import org.springframework.stereotype.Component;
@Component
class TrimProcessor implements TextProcessor {
static final String TRIM_MESSAGE_FORMAT =
"[TrimProcessor] Trimming text: %s%n";
@Override
public void process(TextDocument document) {
System.out.printf(TRIM_MESSAGE, document.getContent());
document.setContent(document.getContent().trim());
}
}
@Component
class CapitalizeProcessor implements TextProcessor {
static final String CAPITALIZE_MESSAGE =
"[CapitalizeProcessor] Capitalizing first letter.";
@Override
public void process(TextDocument document) {
String content = document.getContent();
if (content != null && !content.isEmpty()) {
System.out.println(CAPITALIZE_MESSAGE);
StringBuilder sb = new StringBuilder(content);
char firstChar = sb.charAt(0);
if (Character.isLowerCase(firstChar)) {
sb.setCharAt(0, Character.toUpperCase(firstChar));
}
document.setContent(sb.toString());
}
}
}
@Component
class RemoveExtraSpacesProcessor implements TextProcessor {
static final String REMOVE_SPACES_MESSAGE =
"[RemoveExtraSpacesProcessor] Removing extra spaces.";
@Override
public void process(TextDocument document) {
System.out.println(REMOVE_SPACES_MESSAGE);
// Replace multiple whitespace characters with a single space
document.setContent(document.getContent().replaceAll("\\s+", " "));
}
}Instead of manually defining a chain, we autowire all handlers into a list and iterate over them in the MainProcessor class. This ensures that all registered handlers execute. If order is important, @Order annotations should be used on the TextProcessor components.
import org.springframework.stereotype.Service
@Service
class MainProcessor(
private val processors: List<TextProcessor> // Spring injects all TextProcessor beans
) {
fun process(document: TextDocument) {
processors.forEach { it.process(document) }
}
}import org.springframework.stereotype.Service;
@Service
public class MainProcessor {
// Spring injects all TextProcessor beans
private final List<TextProcessor> processors;
public MainProcessor(List<TextProcessor> processors) {
this.processors = processors;
}
public void process(TextDocument document) {
for (TextProcessor processor : processors) {
processor.process(document);
}
}
}When an instance of TextDocument is passed to MainProcessor, it is processed by each processor in the list. Since Spring automatically injects all available processors, the processing sequence is determined by the order in which Spring discovers the beans. If @Order is used (@Order(1)... @Order(2)...), they will be injected in that specified order.
This approach eliminates the need for manual chaining, making the system flexible and maintainable. If a new processing step is required, simply add a new @Component-annotated handler, and Spring will automatically include it in the execution pipeline.
Observer
The Observer pattern establishes a one-to-many relationship where multiple observers automatically respond whenever a particular event occurs in a publisher (or subject). Spring provides a built-in event-driven mechanism that eliminates the need for manually managing observer lists. Instead of explicitly registering observers, Spring allows event publishing and subscription using ApplicationEventPublisher and @EventListener.
This example demonstrates how to implement an observer system using Spring Events to handle user registration notifications. When a user registers, multiple actions should be triggered:
Sending a welcome email.
Logging the registration in an analytics system.
Using Spring Events, each action becomes a separate event listener. The publisher only needs to fire an event, and Spring ensures all relevant listeners receive it.
A UserRegistrationEvent class represents the event that carries user details.
data class UserRegistrationEvent(
val username: String,
val email: String
)public record UserRegistrationEvent(String username, String email) {
// empty body
}Older Spring versions require events to extend org.springframework.context.ApplicationEvent, modern Spring allows publishing any Plain Old Java/Kotlin Object (POJO) as an event.
This class encapsulates the necessary user data and acts as a notification payload for the event system.
Spring allows us to publish events using ApplicationEventPublisher. Instead of maintaining a list of observers manually, the UserRegistrationService fires an event when a new user registers.
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
@Service
class UserRegistrationService(
private val eventPublisher: ApplicationEventPublisher
) {
fun registerNewUser(username: String, email: String) {
println("[UserRegistrationService] Registering user: $username")
val event = UserRegistrationEvent(username, email)
eventPublisher.publishEvent(event)
println("[UserRegistrationService] Registration completed for $username")
}
}import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class UserRegistrationService {
static final String REGISTRATION_START =
"[UserRegistrationService] Registering user: %s%n";
static final String REGISTRATION_COMPLETE =
"[UserRegistrationService] Registration completed for %s%n";
private final ApplicationEventPublisher eventPublisher;
UserRegistrationService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void registerNewUser(String username, String email) {
System.out.printf(REGISTRATION_START , username);
UserRegistrationEvent event = new UserRegistrationEvent(username, email);
eventPublisher.publishEvent(event);
System.out.printf(REGISTRATION_COMPLETE, username);
}
}This service method:
Registers a new user.
Publishes a UserRegistrationEvent, notifying all relevant event listeners.
Instead of implementing a custom observer interface, we define separate event listeners using @EventListener. These components automatically receive events and handle them accordingly.
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component
@Component
class WelcomeEmailListener {
@EventListener
fun handleUserRegistered(event: UserRegistrationEvent) {
println("[WelcomeEmailListener] Sending welcome email to ${event.email}")
// Actual implementation might integrate with an email service
}
}
@Component
class AnalyticsListener {
@EventListener
fun handleUserRegistered(event: UserRegistrationEvent) {
println("[AnalyticsListener] Recording new user: ${event.username} in analytics dashboard")
// Actual implementation might connect to Google Analytics, Mixpanel, etc.
}
}import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
class WelcomeEmailListener {
static final String WELCOME_EMAIL_MESSAGE =
"[WelcomeEmailListener] Sending welcome email to %s%n";
@EventListener
public void handleUserRegistered(UserRegistrationEvent event) {
System.out.printf(WELCOME_EMAIL_MESSAGE, event.email());
// Actual implementation might integrate with an email service
}
}
@Component
class AnalyticsListener {
static final String ANALYTICS_RECORD_MESSAGE =
"[AnalyticsListener] Recording new user: %s in analytics dashboard%n";
@EventListener
public void handleUserRegistered(UserRegistrationEvent event) {
System.out.printf(ANALYTICS_RECORD_MESSAGE, event.username());
// Actual implementation might connect to Google Analytics, Mixpanel, etc.
}
}Each listener:
Listens for UserRegistrationEvent.
Executes logic independently when the event is published.
Spring automatically discovers and registers these listeners, eliminating the need for manual subscription logic. New observers can be added simply by defining new @Component-annotated classes with @EventListener methods.
By using Spring’s event-handling mechanism, the Observer pattern becomes fully integrated into the framework. This approach simplifies event-driven architectures, improves modularity, and allows dynamic extension without modifying existing services.
Conclusion
Strategy, Chain of Responsibility, and Observer enhance application design by structuring behavior, simplifying request handling, and enabling event-driven communication. With Spring Boot’s dependency injection and event system, Strategy dynamically selects the appropriate logic, Chain of Responsibility processes requests through automatically discovered handlers, and Observer allows components to react to events independently. These patterns reduce tight coupling, improve maintainability, and make it easy to extend functionality without modifying existing code, ensuring a scalable and modular architecture.