Design patterns and SOLID principles are fundamental tools for building maintainable and scalable applications. SOLID provides guidelines for structuring classes and dependencies, while design patterns offer proven solutions to common problems in software design. Let's explore these concepts in relation to spring boot.
SOLID Principles
SOLID is an acronym for five design principles that help write understandable, flexible, and maintainable code.
S - Single Responsibility Principle (SRP)
A class should have only one reason to change, meaning it should have only one job or responsibility. For example:
// VIOLATION // @Service // class UserService { // fun registerUser(data: UserData) { // 1. Validate data // 2. Save user to DB // 3. Send confirmation email // } // } @Service class UserValidationService { fun validate(data: UserData) { /* validate data */ } } @Service class UserPersistenceService(private val repo: UserRepository) { fun saveUser(data: UserData) { /* save user to DB */ } } @Service class NotificationService { fun sendConfirmation(email: String) { /* send confirmation email */ } } // Orchestrator service uses the others @Service class UserRegistrationService( private val validator: UserValidationService, private val persistence: UserPersistenceService, private val notifier: NotificationService ) { fun registerUser(data: UserData) { validator.validate(data) persistence.saveUser(data) notifier.sendConfirmation(data.email) } }// VIOLATION // @Service // class UserService { // public void registerUser(UserData data) { // 1. Validate data // 2. Save user to DB // 3. Send confirmation email // } // } @Service class UserValidationService { public void validate(UserData data) { /* validate data */ } } @Service class UserPersistenceService { private final UserRepository repo; public UserPersistenceService(UserRepository repo) { this.repo = repo; } public void saveUser(UserData data) { /* save user to DB */ } } @Service class NotificationService { public void sendConfirmation(String email) { /* send confirmation email */ } } // Orchestrator service uses the others @Service class UserRegistrationService { private final UserValidationService validator; private final UserPersistenceService persistence; private final NotificationService notifier; public UserRegistrationService( UserValidationService validator, UserPersistenceService persistence, NotificationService notifier ) { this.validator = validator; this.persistence = persistence; this.notifier = notifier; } public void registerUser(UserData data) { validator.validate(data); persistence.saveUser(data); notifier.sendConfirmation(data.getEmail()); } }Instead of a single spring service handling user data retrieval, validation, and notification, separate these concerns into distinct components.
O - Open/Closed Principle (OCP)
Entities should be open for extension but closed for modification. Use abstraction (interfaces, abstract classes) to allow adding new functionality without changing existing, tested code. Here's an example using different implementations of a
PaymentProcessorinterface:interface PaymentProcessor { val supportedMethod: String // To identify the processor fun processPayment(amount: Double): Boolean } @Component("creditCardProcessor") class CreditCardProcessor : PaymentProcessor { override val supportedMethod = "CREDIT_CARD" override fun processPayment(amount: Double): Boolean { /*...*/ } } @Component("paypalProcessor") class PayPalProcessor : PaymentProcessor { override val supportedMethod = "PAYPAL" override fun processPayment(amount: Double): Boolean { /*...*/ } } // New payment methods (e.g. crypto) can be added just by creating a new payment processor component // Order service won't need modification when adding new payment methods @Service class OrderService(processors: List<PaymentProcessor>) { // injects all implementations private val processorMap = processors.associateBy { it.supportedMethod } fun placeOrder(amount: Double, method: String) { val processor = processorMap[method] ?: // handle case processor.processPayment(amount) } }interface PaymentProcessor { String getSupportedMethod(); // To identify the processor boolean processPayment(double amount); } @Component("creditCardProcessor") class CreditCardProcessor implements PaymentProcessor { @Override public String getSupportedMethod() { return "CREDIT_CARD"; } @Override public boolean processPayment(double amount) { /*...*/ } } @Component("paypalProcessor") class PayPalProcessor implements PaymentProcessor { @Override public String getSupportedMethod() { return "PAYPAL"; } @Override public boolean processPayment(double amount) { /*...*/ } } // New payment methods (e.g. crypto) can be added // just by creating a new payment processor component // Order service won't need modification when adding new payment methods @Service class OrderService { // injects all implementations private final Map<String, PaymentProcessor> processorMap; public OrderService(List<PaymentProcessor> processors) { this.processorMap = processors .stream() .collect(Collectors.toMap( PaymentProcessor::getSupportedMethod, Function.identity() )); } public void placeOrder(double amount, String method) { PaymentProcessor processor = processorMap.get(method); if (processor == null) { // handle case } processor.processPayment(amount); } }L - Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering the correctness of the program. If
Sis a subtype ofT, objects ofTshould be replaceable with objects ofSwithout issues:interface DocumentRepository { fun findById(id: String): Document? } // Example implementation #1 @Repository class DatabaseDocumentRepository : DocumentRepository { override fun findById(id: String): Document? { /*...*/ } } // Example implementation #2 @Repository class InMemoryDocumentRepository : DocumentRepository { override fun findById(id: String): Document? { /*...*/ } } // A service using [DocumentRepository] should work correctly with // either implementation @Service class DocumentService(private val repo: DocumentRepository) { fun getDocumentContent(id: String): String? { val doc = repo.findById(id) // expects [Document] or null return doc?.content } }interface DocumentRepository { Document findById(String id); } // Example implementation #1 @Repository class DatabaseDocumentRepository implements DocumentRepository { @Override public Document findById(String id) { /*...*/ } } // Example implementation #2 @Repository class InMemoryDocumentRepository implements DocumentRepository { @Override public Document findById(String id) { /*...*/ } } // A service using [DocumentRepository] should work correctly with // either implementation @Service class DocumentService { private final DocumentRepository repo; public DocumentService(DocumentRepository repo) { this.repo = repo; } public String getDocumentContent(String id) { Document doc = repo.findById(id); // expects [Document] or null return (doc != null) ? doc.getContent() : null; } }I - Interface Segregation Principle (ISP)
Classes should not be forced to depend on methods they do not use. Prefer smaller, specific interfaces over large, monolithic ones. For example:
// VIOLATION // interface AdminService { // fun manageUser(userId: String) // fun updateUserProfile(userId: String, profile: Map<String, Any>) // fun addProduct(product: Product) // fun updateProductStock(productId: String, quantity: Int) // fun getSystemSetting(key: String): String // fun setSystemSetting(key: String, value: String) // } interface UserManagement { fun manageUser(userId: String) fun updateUserProfile(userId: String, profile: Map<String, Any>) } interface ProductCatalog { fun addProduct(product: Product) fun updateProductStock(productId: String, quantity: Int) } interface SystemSettings { fun getSystemSetting(key: String): String fun setSystemSetting(key: String, value: String) }// VIOLATION // interface AdminService { // void manageUser(String userId); // void updateUserProfile(String userId, Map<String, Object> profile); // void addProduct(Product product); // void updateProductStock(String productId, int quantity); // String getSystemSetting(String key); // void setSystemSetting(String key, String value); // } interface UserManagement { void manageUser(String userId); void updateUserProfile(String userId, Map<String, Object> profile); } interface ProductCatalog { void addProduct(Product product); void updateProductStock(String productId, int quantity); } interface SystemSettings { String getSystemSetting(String key); void setSystemSetting(String key, String value); }Instead of one large
AdminServiceinterface with methods for users, products, and settings, split it. Implementations can then implement one or more specific interfaces when needed.D - Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules, but they should depend on abstractions. For example, a
ReportGeneratorservice should depend on aDataSourceinterface, not a concrete implementation ofDataSource:interface DataSource { fun fetchData(): List<String> } @Component("dbSource") @Profile("production") class DatabaseDataSource : DataSource { override fun fetchData(): List<String> { /*...*/ } } @Component("apiSource") @Profile("staging") class ApiDataSource : DataSource { override fun fetchData(): List<String> { /*...*/ } } // High-level module depends on the abstraction @Service class ReportGenerator(@Autowired private val dataSource: DataSource) { fun generateReport(): String { val data = dataSource.fetchData() return "Report:\n${data.joinToString("\n")}" } }interface DataSource { List<String> fetchData(); } @Component("dbSource") @Profile("production") class DatabaseDataSource implements DataSource { @Override public List<String> fetchData() { /*...*/ } } @Component("apiSource") @Profile("staging") class ApiDataSource implements DataSource { @Override public List<String> fetchData() { /*...*/ } } // High-level module depends on the abstraction @Service class ReportGenerator { private final DataSource dataSource; @Autowired public ReportGenerator(DataSource dataSource) { this.dataSource = dataSource; } public String generateReport() { List<String> data = dataSource.fetchData(); return "Report:\n" + String.join("\n", data); } }In the application's properties or configuration, you can set the active profile (e.g. "production" or "staging") to control with
DataSourceimplementation is injected.
Common Design Patterns in Spring
Spring leverages many design patterns internally and makes implementing them easier through its features like IoC/DI, AOP, and event handling.
Singleton
Ensures a class has only one instance and provides a global point of access to it. By default, spring beans (
@Component,@Service,@Repository,@Controller, beans defined via@Bean) are singletons within the application context. Spring manages their lifecycle and ensures only one instance is created and reused.@Service // This bean will be a singleton by default class ConfigurationService { private val configCache = mutableMapOf<String, String>() fun getConfig(key: String): String? = configCache[key] fun setConfig(key: String, value: String) { configCache[key] = value } } // Any component injecting [ConfigurationService] will get the same instance @RestController class ApiController(@Autowired private val configService: ConfigurationService) { /*...*/ }@Service // This bean will be a singleton by default class ConfigurationService { private final Map<String, String> configCache = new HashMap<>(); public String getConfig(String key) { return configCache.get(key); } public void setConfig(String key, String value) { configCache.put(key, value); } } // Any component injecting [ConfigurationService] will get the same instance @RestController class ApiController { private final ConfigurationService configService; @Autowired public ApiController(ConfigurationService configService) { this.configService = configService; } /*...*/ }Spring's singleton scope is different from the classic singleton pattern (e.g., using a static instance) in that it guarantees one instance per container, not per JVM.
Proxy
Provides a placeholder for another object to control access to it. Used for lazy initialization, access control, logging, transactions, etc. Spring AOP (Aspect-Oriented Programming) heavily relies on proxies (JDK dynamic proxies for interfaces, CGLIB for classes) to add concerns like declarative transactions (
@Transactional), security (@Secured), caching (@Cacheable), and asynchronous execution (@Async).interface AccountService { fun deposit(accountId: String, amount: Double) fun getBalance(accountId: String): Double } @Service open class AccountServiceImpl : AccountService { // 'open' needed for CGLIB if not implementing interface @Transactional // Spring AOP creates a proxy around this bean override fun deposit(accountId: String, amount: Double) { // Transactional logic (begin, commit/rollback) is added by the proxy // Deposit logic that throws an exception on failure, triggering a rollback } // Not transactional unless annotated override fun getBalance(accountId: String): Double { /*...*/ } }interface AccountService { void deposit(String accountId, double amount); double getBalance(String accountId); } @Service class AccountServiceImpl implements AccountService { @Override @Transactional // Spring AOP creates a proxy around this bean public void deposit(String accountId, double amount) { // Transactional logic (begin, commit/rollback) is added by the proxy // Deposit logic that throws an exception on failure, triggering a rollback } // Not transactional unless annotated @Override public double getBalance(String accountId) { /*...*/ } }When
depositis called on an injectedAccountServicebean, the call goes through the proxy, which starts a transaction before calling the actual method and commits/rolls back after.Command
Encapsulates a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations. Commands can be implemented as spring beans (
@Component). A central service can execute these commands, decoupling the invoker from the action logic.interface Command { fun execute() } @Component("emailCommand") class SendEmailCommand(private val payload: EmailPayload) : Command { override fun execute() { /*...*/ } } @Component class CommandFactory { fun createEmailCommand(payload: EmailPayload): Command { return SendEmailCommand(payload) } } @Service class CommandExecutorService(@Autowired private val factory: CommandFactory) { fun submitEmailCommand(payload: EmailPayload) { val command = factory.createEmailCommand(payload) // Could queue it or execute immediately for simplicity here executeCommand(command) } private fun executeCommand(command: Command) { try { command.execute() } catch (ex: Exception) { // Handle error } } }interface Command { void execute(); } @Component("emailCommand") class SendEmailCommand implements Command { private final EmailPayload payload; public SendEmailCommand(EmailPayload payload) { this.payload = payload; } @Override public void execute() { /*...*/ } } @Component class CommandFactory { public Command createEmailCommand(EmailPayload payload) { return new SendEmailCommand(payload); } } @Service class CommandExecutorService { private final CommandFactory factory; @Autowired public CommandExecutorService(CommandFactory factory) { this.factory = factory; } public void submitEmailCommand(EmailPayload payload) { Command command = factory.createEmailCommand(payload); // Could queue it or execute immediately for simplicity here executeCommand(command); } private void executeCommand(Command command) { try { command.execute(); } catch (Exception ex) { // Handle error } } }Strategy
Defines a family of algorithms, encapsulates each one, and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it. Different strategies are implemented as spring beans, often sharing a common interface. The context (e.g. a service) selects the appropriate strategy based on input or configuration, leveraging spring's DI to inject available strategies.
interface NotificationStrategy { val strategyType: String fun send(userId: String, message: String) } @Component class EmailNotificationStrategy : NotificationStrategy { override val strategyType = "EMAIL" override fun send(userId: String, message: String) { /*...*/ } } @Component class SmsNotificationStrategy : NotificationStrategy { override val strategyType = "SMS" override fun send(userId: String, message: String) { /*...*/ } } @Configuration class NotificationStrategyConfig { @Bean fun notificationStrategies(strategies: List<NotificationStrategy>): Map<String, NotificationStrategy> { return strategies.associateBy { it.strategyType } } } @Service class UserNotifierService( // Inject map created by the configuration bean private val strategies: Map<String, NotificationStrategy> ) { fun notifyUser(userId: String, message: String, method: String) { val strategy = strategies[method] ?: // handle case strategy.send(userId, message) } }interface NotificationStrategy { String getStrategyType(); void send(String userId, String message); } @Component class EmailNotificationStrategy implements NotificationStrategy { @Override public String getStrategyType() { return "EMAIL"; } @Override public void send(String userId, String message) { /*...*/ } } @Component class SmsNotificationStrategy implements NotificationStrategy { @Override public String getStrategyType() { return "SMS"; } @Override public void send(String userId, String message) { /*...*/ } } @Configuration class NotificationStrategyConfig { @Bean public Map<String, NotificationStrategy> notificationStrategies(List<NotificationStrategy> strategies) { return strategies.stream() .collect(Collectors.toMap(NotificationStrategy::getStrategyType, Function.identity())); } } @Service class UserNotifierService { // Inject map created by the configuration bean private final Map<String, NotificationStrategy> strategies; public UserNotifierService(Map<String, NotificationStrategy> strategies) { this.strategies = strategies; } public void notifyUser(String userId, String message, String method) { NotificationStrategy strategy = strategies.get(method); if (strategy == null) { // handle case } strategy.send(userId, message); } }The example on "Open/Closed Principle (OCP)" provides another example of this pattern.
Chain of Responsibility
Avoids coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chains the receiving objects and passes the request along the chain until an object handles it. Handlers can be implemented as spring beans implementing a common interface. They can be injected as an ordered
List(using@OrderorOrderedinterface) into a service that iterates through them, allowing each handler to process or pass the request.interface OrderProcessingStep { fun handle(request: OrderRequest) } @Component @Order(1) // Defines order of execution class ValidationHandler: OrderProcessingStep { override fun handle(request: OrderRequest) { /* validate order */ } } @Component @Order(2) class InventoryHandler: OrderProcessingStep { override fun handle(request: OrderRequest) { // check inventory for order } } @Component @Order(3) class PaymentHandler: OrderProcessingStep { override fun handle(request: OrderRequest) { // payment process } } @Service class OrderProcessor( // Spring injects all [OrderProcessingStep] beans, ordered by @Order private val steps: List<OrderProcessingStep> ) { fun processOrder(request: OrderRequest) { try { steps.forEach { it.handle(request) } // ... } catch (ex: Exception) { // handle error } } }interface OrderProcessingStep { void handle(OrderRequest request); } @Component @Order(1) // Defines order of execution class ValidationHandler implements OrderProcessingStep { @Override public void handle(OrderRequest request) { /* validate order */ } } @Component @Order(2) class InventoryHandler implements OrderProcessingStep { @Override public void handle(OrderRequest request) { // check inventory for order } } @Component @Order(3) class PaymentHandler implements OrderProcessingStep { @Override public void handle(OrderRequest request) { // payment process } } @Service class OrderProcessor { // Spring injects all [OrderProcessingStep] beans, ordered by @Order private final List<OrderProcessingStep> steps; public OrderProcessor(List<OrderProcessingStep> steps) { this.steps = steps; } public void processOrder(OrderRequest request) { try { steps.forEach(step -> step.handle(request)); // ... } catch (Exception ex) { // handle error } } }You could also add logic to stop the chain after each
handlecall if a failure occurs.Observer
Defines a one-to-many dependency between objects so that when one object (the subject) changes state, all its dependents (observers) are notified and updated automatically. Spring's application event mechanism provides a powerful implementation. Publish events using
ApplicationEventPublisherand create listeners (observers) using the@EventListenerannotation on methods within spring beans. This decouples publishers from listeners.data class ProductPriceChangedEvent( val productId: String, val oldPrice: Double, val newPrice: Double ) // Publisher service @Service class ProductService(private val eventPublisher: ApplicationEventPublisher) { private val prices = mutableMapOf("PROD1" to 100.0, "PROD2" to 50.0) fun updatePrice(productId: String, newPrice: Double) { val oldPrice = prices[productId] ?: return if (oldPrice != newPrice) { prices[productId] = newPrice // Publish an event val event = ProductPriceChangedEvent(productId, oldPrice, newPrice) eventPublisher.publishEvent(event) // Spring notifies listeners } } } // Listener #1: Update cache @Component class CacheUpdateListener { @EventListener // Subscribes to the event type fun handlePriceChange(event: ProductPriceChangedEvent) { /*...*/ } } // Listener #2: Notify marketing @Component class MarketingNotificationListener { @EventListener fun handlePriceChange(event: ProductPriceChangedEvent) { /*...*/ } } // productService.updatePrice("PROD1", 90.0) // Both listeners will be invokedpublic record ProductPriceChangedEvent(String productId, double oldPrice, double newPrice) { } // Publisher service @Service class ProductService { private final ApplicationEventPublisher eventPublisher; private final Map<String, Double> prices = new HashMap<>(Map.of("PROD1", 100.0, "PROD2", 50.0)); public ProductService(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } public void updatePrice(String productId, double newPrice) { Double oldPrice = prices.get(productId); if (oldPrice == null) { return; } if (oldPrice != newPrice) { prices.put(productId, newPrice); // Publish an event var event = new ProductPriceChangedEvent(productId, oldPrice, newPrice); eventPublisher.publishEvent(event); // Spring notifies listeners } } } // Listener #1: Update cache @Component class CacheUpdateListener { @EventListener // Subscribes to the event type public void handlePriceChange(ProductPriceChangedEvent event) { /*...*/ } } // Listener #2: Notify marketing @Component class MarketingNotificationListener { @EventListener public void handlePriceChange(ProductPriceChangedEvent event) { /*...*/ } } // productService.updatePrice("PROD1", 90.0); // Both listeners will be invoked
To learn more about design patterns, check out the Overview of patterns in Spring Boot topic for the strategy, chain of responsibility, and observer patterns. For singleton, proxy, and command, head over to the Patterns in Spring Boot topic.
Final Thoughts
Applying SOLID principles and recognized design patterns within spring leads to applications that are easier to test, extend, and maintain. Spring's features like dependency injection, AOP, and the application event model provide direct support for implementing these concepts cleanly, reducing boilerplate code and promoting loose coupling between components. Mastering these is essential for developing solid spring applications.