Computer scienceProgramming languagesJavaInterview preparationTech interviewFrameworks and design

Patterns and SOLID interview

10 minutes read

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 PaymentProcessor interface:

    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 S is a subtype of T, objects of T should be replaceable with objects of S without 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 AdminService interface 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 ReportGenerator service should depend on a DataSource interface, not a concrete implementation of DataSource:

    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 DataSource implementation 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 deposit is called on an injected AccountService bean, 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 @Order or Ordered interface) 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 handle call 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 ApplicationEventPublisher and create listeners (observers) using the @EventListener annotation 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 invoked
    public 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.

7 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo