SOLID as a Question About Change: Refactoring a Spring Boot Service
Learn to apply each SOLID principle in a Spring Boot backend by asking what is likely to change, and how to avoid the over-engineering that SOLID often invites.
This article is published in English.
Sources: SOLID Principles for Java Developers: What 5 Years of Backend Development Taught Me
Most developers can recite what the five letters of SOLID stand for, yet very few can explain how those letters should shape a real Spring Boot service with repositories, payment providers and notification channels. Definitions alone do not help when a product manager asks for WhatsApp notifications on a Friday afternoon. This guide walks through each principle using typical backend code, shows the refactoring that each one suggests, and, just as importantly, shows where applying the principle mechanically makes a codebase worse.
The common thread across the five principles is not "more interfaces" or "tiny classes". It is keeping code cheap to change, so that modifying one behavior does not ripple through everything around it. The examples below are Java, but the reasoning transfers directly to TypeScript services in Node.js; if you prefer that setting, there is a companion piece on SOLID through practical JavaScript examples.
The problem SOLID is trying to prevent
Start with an order service that does everything a checkout needs in one method: it validates, persists, emails, invoices, notifies and records analytics.
public class OrderService {
public void createOrder(Order order) {
validateOrder(order);
saveOrder(order);
sendEmail(order);
generateInvoice(order);
sendNotification(order);
updateAnalytics(order);
}
}
Nothing here is broken. The logic reads top to bottom, it would pass a code review in a hurry, and it could run in production for months. Trouble arrives with the requirements that follow. One stakeholder wants notifications to go through WhatsApp rather than email. Another wants invoice generation moved to a background job. The data team announces a database migration. Then analytics has to be published to Kafka.
Every one of those requests lands in the same class. The code still works; the problem is that each small change forces you to open, edit and retest a unit that is entangled with five other concerns. That coupling, where one change drags many others along with it, is exactly what the SOLID principles are meant to keep in check.
Single Responsibility: count the reasons to change
The Single Responsibility Principle says a class should have one responsibility, which in practice means one reason to change. It is frequently misread as "one method per class". That reading is wrong: a class with twenty methods can still have a single responsibility, as long as all of them serve the same concern.
The useful question is not how big the class is, but what could force you to edit it. If you can list several unrelated answers, the class is probably carrying several responsibilities.
Spotting mixed responsibilities
Here is a user registration service in a style you will find in many codebases.
public class UserService {
public void registerUser(User user) {
validate(user);
saveToDatabase(user);
sendEmail(user);
generateReport(user);
}
}
It does four distinct jobs: validating the user, persisting it, sending email and producing a report. That gives it four independent reasons to change. A new email vendor means editing UserService. A switch to a different database means editing UserService. A change in reporting format means editing UserService again. When unrelated teams keep touching the same file, you are looking at a design smell.
Delegating to collaborators
The refactoring keeps the orchestration in UserService and pushes the specialised work into dedicated collaborators injected through the constructor.
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
public void registerUser(User user) {
validate(user);
userRepository.save(user);
emailService.sendWelcomeEmail(user);
}
}
The resulting dependency structure is simple to picture:
UserService
|
+---- UserRepository
|
+---- EmailService
UserService now decides what happens during registration, while UserRepository owns persistence and EmailService owns delivery. Validation stays in the service because it is part of the registration rule itself, and reporting leaves the registration path entirely.
The habit to build is to stop counting methods and instead ask how many different forces could make you modify the class. That question points you at real coupling rather than at superficial size.
Open/Closed: add behavior without reopening stable code
The formal wording, that software entities should be open for extension but closed for modification, sounds abstract. Translated into everyday terms: you should be able to introduce new behavior without repeatedly editing code that already works and has been tested.
The growing conditional
Payment handling is the classic example. A first version often dispatches on a string.
public class PaymentService {
public void pay(String type) {
if (type.equals("CARD")) {
// card payment
}
if (type.equals("UPI")) {
// UPI payment
}
if (type.equals("NET_BANKING")) {
// net banking
}
}
}
This is fine for three methods. Then the business asks for wallets, PayPal, Apple Pay and bank transfers. Each addition means reopening PaymentService, extending the conditional and retesting every branch that was already there. The class slowly becomes a long if/else chain.
Replacing branches with polymorphism
The Open/Closed approach introduces an abstraction that describes what every payment method must do.
public interface PaymentProcessor {
void process(Payment payment);
}
Each payment method then becomes its own implementation. The card processor:
public class CardPaymentProcessor
implements PaymentProcessor {
@Override
public void process(Payment payment) {
// card payment
}
}
And the UPI processor:
public class UpiPaymentProcessor
implements PaymentProcessor {
@Override
public void process(Payment payment) {
// UPI payment
}
}
Adding a new provider now means writing a new class rather than editing existing ones. The processors that are already in production are left untouched, which shrinks the blast radius of each change. In a Spring application you would typically register every processor as a bean and select the right one through a map keyed by payment type, so even the selection logic does not need to grow a new branch.
When an interface buys you nothing
None of this implies that every class needs an interface. A very common pattern in Spring projects is the one-to-one pairing of an interface and its only implementation:
public interface UserService {
void createUser();
}
with a single class behind it:
public class UserServiceImpl implements UserService {
}
If there is no realistic prospect of a second implementation, and nothing gains from substituting one, this interface is just an extra file to navigate. Modern mocking libraries can mock concrete classes, so testability alone is rarely a strong argument either. SOLID is about designing for the changes you expect, not about wrapping every class in an abstraction by reflex.
Liskov Substitution: honour the contract, not just the signature
Liskov Substitution states that you should be able to use any subtype wherever its parent type is expected without breaking the correctness of the program. Of the five principles, it tends to feel the most academic, so it helps to reuse the payment example.
The abstraction is the same as before:
public interface PaymentProcessor {
void process(Payment payment);
}
A card implementation does what the interface promises:
public class CardPaymentProcessor
implements PaymentProcessor {
public void process(Payment payment) {
// process payment
}
}
So far, so good. Then someone adds a processor for cash payments that cannot actually process anything online:
public class CashPaymentProcessor
implements PaymentProcessor {
public void process(Payment payment) {
throw new UnsupportedOperationException();
}
}
The compiler is satisfied, because the method signature matches. The behavior, however, breaks the promise. Any caller holding a PaymentProcessor reasonably assumes that calling process will process a payment. With this implementation, a harmless-looking loop over all processors can suddenly throw at runtime, and callers start adding instanceof checks or try/catch blocks to protect themselves, which is a sure sign the abstraction has leaked.
The question to ask when writing an implementation is whether it can genuinely behave the way the abstraction promises. If the honest answer is no, the fix is usually to change the abstraction, for example by modelling offline payments separately or by splitting the capability into a narrower interface. Liskov is about behavioral correctness, not about whether the implements keyword compiles.
Interface Segregation: keep consumers from depending on what they ignore
Interface Segregation says that no client should be forced to depend on methods it does not use. Large, catch-all interfaces are where this principle usually shows up.
An interface that serves everyone
Consider an interface that bundles employee management, payroll, leave approval and reporting:
public interface EmployeeOperations {
void createEmployee();
void updateEmployee();
void deleteEmployee();
void generateSalary();
void approveLeave();
void generateReport();
}
A reporting component only ever needs a single operation from it:
generateReport();
Yet by depending on EmployeeOperations it is coupled to creation, deletion, salary and leave logic it will never call.
Splitting along consumer needs
A better arrangement exposes separate, focused contracts. One for managing employee records:
public interface EmployeeManagement {
void createEmployee();
void updateEmployee();
void deleteEmployee();
}
And one for reporting:
public interface EmployeeReporting {
void generateReport();
}
Each consumer now depends only on the capability it uses. One class may still implement both; segregation concerns what clients see.
Why it matters as the system grows
In a small application an oversized interface is a minor annoyance. In a large one, it becomes a web of accidental coupling where a change for one consumer affects many unrelated ones. Smaller interfaces make dependency boundaries explicit and easier to reason about.
The caveat is the same as with Open/Closed. Do not slice interfaces into fragments just to tick a box. Split them when different clients really do need different subsets of behavior.
Dependency Inversion: let business logic depend on abstractions
Dependency Inversion asks that business-level code and infrastructure code both rely on shared abstractions, rather than the policy layer referring to concrete low-level classes. In Spring Boot applications this principle is arguably the most consequential of the five.
Hard-wired infrastructure
Here the order service instantiates its own repository:
public class OrderService {
private final MySqlOrderRepository repository;
public OrderService() {
this.repository = new MySqlOrderRepository();
}
}
The business logic now knows precisely which database technology stores its data. Replacing MySQL, or substituting a fake repository in a unit test, requires editing OrderService itself. The high-level policy is chained to a low-level detail.
Inverting the dependency
First define what the business logic actually needs, independent of storage technology:
public interface OrderRepository {
void save(Order order);
}
Then make the MySQL-specific class one implementation of that contract:
@Repository
public class MySqlOrderRepository
implements OrderRepository {
@Override
public void save(Order order) {
// save order
}
}
Finally, have the service receive the abstraction through its constructor:
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
OrderService no longer cares where orders end up. The implementation could be MySQL, PostgreSQL, MongoDB, a call to another service or an in-memory mock used in tests. The direction of knowledge has flipped: the infrastructure conforms to an interface owned by the business layer, instead of the business layer adapting to the infrastructure.
How Spring's dependency injection supports these principles
Spring makes Dependency Inversion feel natural because its container does the wiring. When a service declares its collaborators in the constructor, as below, Spring finds a bean that matches the OrderRepository type and passes it in.
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
The class never constructs its own dependencies. Contrast that with direct instantiation inside business code:
OrderRepository repository =
new MySqlOrderRepository();
That single line ties the caller to a concrete class and makes substitution impossible without editing it. Constructor injection keeps the coupling at the level of the abstraction and makes dependencies visible in the class signature, which is also why it is generally preferred over field injection. Keep in mind that dependency injection is a mechanism and Dependency Inversion is a design principle: the container helps you wire abstractions, but you still have to choose which abstractions to define.
SOLID does not mean more classes
Teams that have just discovered SOLID sometimes overshoot. A single user feature becomes a dozen types:
UserValidator
UserCreator
UserUpdater
UserRepository
UserMapper
UserConverter
UserEmailService
UserNotificationService
UserFactory
UserBuilder
UserManager
UserProcessor
Multiply that across a codebase and you end up with forty classes where eight would have expressed the same design more clearly. That is over-engineering, not good architecture. The principles exist to reduce complexity; if applying one makes the code harder to follow, you are probably applying it by rote rather than in response to an actual source of change.
Start from what is likely to change
Rather than asking where SOLID could be applied, experienced backend engineers tend to begin with a different question: what in this component is likely to change? The answer maps quite directly to the principles:
- The database may change: introduce a repository abstraction.
- Payment providers may change: put providers behind a payment abstraction.
- Notification channels may change: separate notification behavior from the business flow.
- Business rules keep growing: separate responsibilities before one class absorbs them all.
- Different consumers need different operations: split the interfaces they depend on.
- Implementations do not truly behave alike: revisit the abstraction instead of forcing them under it.
Framing it this way keeps the principles grounded. You add abstractions where volatility is real and leave stable code simple.
Applying the same thinking to service boundaries
The reasoning scales beyond individual classes. Imagine one deployable service responsible for all of these domains:
User
Payment
Notification
Reporting
Inventory
Shipping
It may run perfectly well, but users, payments, notifications, reporting, inventory and shipping change for different reasons and often on different timelines owned by different teams. The logic behind Single Responsibility can help you spot where natural boundaries lie.
That does not mean every domain concept deserves its own microservice. SOLID is not a mandate for more services; it is a guide to separating responsibilities and dependencies well, whether those boundaries end up as modules, packages or services.
A review checklist for backend components
Before you consider a significant backend component finished, run through one question per principle:
- Single Responsibility: does this class have several unrelated reasons to change?
- Open/Closed: can a new variation be added without editing stable, existing logic?
- Liskov Substitution: does every implementation genuinely honour the contract of its abstraction?
- Interface Segregation: are any clients depending on methods they never call?
- Dependency Inversion: is the business logic coupled to a concrete implementation it should not know about?
Honest answers to these questions are more valuable than being able to recite the definitions.
Key takeaways
- The goal behind all five principles is the same: make the next change cheap and contained, so that swapping a database, adding a payment provider or changing a notification channel feels routine rather than risky.
- Read each principle as a question. A large service prompts "what is changing here?", a ten-method interface prompts "who needs all of these?", and a
new SomeImplementation()in business logic prompts "does this class need to know about that?". - Introduce abstractions where change is plausible, and resist them where it is not; an interface with one permanent implementation, or a dozen classes for one feature, adds cost without protection.
- Liskov violations usually signal a wrong abstraction, so fix the model rather than patching callers.
- Write code that works today, and structure it so that tomorrow's modification is no harder than today's.