
- Published on
- ·12 min read
SOLID Principles and Code Smells in Spring Boot — Where They Actually Show Up
- Authors

- Name
- Bert / DOTUNE
- Developer
Most SOLID articles stop at five definitions with one toy example each. That's not where the value is. In a real Spring Boot codebase, a violated principle doesn't announce itself as "this violates the Single Responsibility Principle." It shows up as a concrete smell: a service class that's 400 lines long, a switch statement you have to edit every time you add a type, an interface that forces callers to depend on methods they never use.
This article maps each principle to the smell it prevents and the refactor that fixes it, in Spring Boot terms. The map first, then the detail.
| Principle | Smell it prevents | Typical fix |
|---|---|---|
| Single Responsibility | God class / fat service | Extract class |
| Open/Closed | switch / if-else dispatch that keeps growing | Strategy pattern, Spring bean map |
| Liskov Substitution | Subclass that can't honor its contract | Split the interface, or compose |
| Interface Segregation | Fat interface | Narrow the interface |
| Dependency Inversion | Depending on concrete infrastructure | Depend on a domain interface |
Single Responsibility — the fat service
The most common SOLID violation in Spring Boot is also the easiest to spot: a @Service that has accumulated everything that touches its domain — and a few things that don't. The smell has concrete markers: a class over a few hundred lines, more than a handful of injected dependencies, or a class whose methods have nothing to do with each other.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final UserRepository userRepository;
private final InventoryClient inventoryClient;
private final EmailSender emailSender;
private final ReportRepository reportRepository;
// ...and the list keeps growing
public Order createOrder(CreateOrderRequest req) { /* validate, save, deduct stock */ }
public User registerUser(UserRequest req) { /* what is this doing here */ }
public void notifyCustomer(Order order) { /* sends email */ }
public byte[] generateMonthlyReport() { /* also unrelated */ }
}
The tell is the phrase "one more method won't hurt." The fix is mechanical: pull each unrelated responsibility into its own class, and let OrderService depend on them instead of doing them.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
private final NotificationService notificationService;
public Order createOrder(CreateOrderRequest req) {
// order lifecycle only — the other concerns live elsewhere
}
}
A useful discipline: give each service one public business method. Sub-steps become private helpers or calls to other focused services. When a method starts needing its own collaborators, that's the signal to promote it. The extreme case is the Action pattern — one focused class per use case — which took one real-world StatementServiceImpl from 2,528 lines to roughly 400.
The "reason to change" test is the practical version of the definition. If you can name two unrelated reasons this class would need to be edited, it has more than one responsibility.
Open/Closed — the switch that never stops growing
A switch (or an if/else chain) that dispatches on a type is the classic Open/Closed violation, and Spring Boot projects accumulate them quickly.
@Service
public class NotificationService {
public void notify(String type, User user, String message) {
switch (type) {
case "email": emailSender.send(user.getEmail(), message); break;
case "sms": smsSender.send(user.getPhone(), message); break;
case "push": pushSender.send(user.getDeviceToken(), message); break;
default: throw new IllegalArgumentException("unknown type: " + type);
}
}
}
Every new notification channel means editing this method. The fix leans on a Spring feature that's easy to overlook: you can inject every implementation of an interface as a Map, keyed by bean name.
public interface Notifier {
void send(User user, String message);
}
@Service("email")
public class EmailNotifier implements Notifier { /* ... */ }
@Service("sms")
public class SmsNotifier implements Notifier { /* ... */ }
@Service
public class NotificationService {
private final Map<String, Notifier> notifiers; // {"email": EmailNotifier, "sms": SmsNotifier}
private final Notifier defaultNotifier;
public NotificationService(Map<String, Notifier> notifiers,
@Qualifier("email") Notifier defaultNotifier) {
this.notifiers = notifiers;
this.defaultNotifier = defaultNotifier;
}
public void notify(String type, User user, String message) {
notifiers.getOrDefault(type, defaultNotifier).send(user, message);
}
}
Adding a new channel is now a single new @Service bean. NotificationService doesn't change — that's the open/closed part. The switch hasn't been deleted; it's been replaced by the Spring container's own lookup, which is exactly where that dispatch belongs.
Liskov Substitution — the subtle one
LSP is the principle people can recite but rarely catch in the wild, because it shows up as inheritance that almost works. The signature case in a Spring Boot app is a class that implements an interface but can't honor part of it:
public interface UserRepository {
User save(User user);
User findById(Long id);
void delete(Long id);
}
@Repository
public class ReadOnlyUserRepository implements UserRepository {
public User save(User user) {
throw new UnsupportedOperationException("read-only repository");
}
public void delete(Long id) {
throw new UnsupportedOperationException("read-only repository");
}
}
This compiles, and it even runs — until someone calls save on the read-only repository and gets a runtime exception. The contract promised by UserRepository is not actually satisfiable by every implementor, which is the precise definition of an LSP violation.
The fix is to stop pretending these are the same abstraction. Split the interface:
public interface UserReader {
User findById(Long id);
}
public interface UserWriter {
User save(User user);
void delete(Long id);
}
@Repository
public class ReadOnlyUserRepository implements UserReader { /* only what it can do */ }
A subclass (or implementing class) throwing UnsupportedOperationException is a reliable marker — it means the abstraction is too wide, and the type hierarchy is lying about what it provides.
Interface Segregation — the fat interface
ISP is the flip side of LSP: instead of an implementor that can't fulfill a contract, it's a caller that's forced to depend on methods it doesn't use.
public interface UserService {
User register(UserRequest req);
User findById(Long id);
void sendPasswordReset(User user); // used by exactly one consumer
List<User> exportToCsv(); // used only by the admin controller
}
Every consumer of this interface now depends on four methods when it needs one. The smaller the interface, the smaller the blast radius when it changes.
In Spring Data, the same principle applies to repositories. The reflexive extends JpaRepository<User, Long> pulls in a couple dozen methods you mostly don't use:
// instead of JpaRepository, use the minimal marker interface and declare only what you need
public interface UserRepository extends Repository<User, Long> {
User findByEmail(String email);
}
You get exactly the query methods your code calls, nothing else. It's a one-line change that keeps the repository's surface honest.
Dependency Inversion — it's about direction, not injection
Spring already gives you dependency injection, which makes this the easiest principle to think you're following when you aren't. DI is how a dependency gets provided. DIP is about what you depend on — specifically, that high-level business logic shouldn't depend on low-level infrastructure, in either direction.
The failure mode is a service that depends on a concrete infrastructure type directly:
@Service
public class UserService {
private final JdbcTemplate jdbcTemplate; // concrete, framework-specific, hard to fake
public User findByEmail(String email) {
return jdbcTemplate.queryForObject(/* ... */);
}
}
UserService now knows how it's persisted, which means every test needs a database and swapping storage means rewriting business code. The fix is to depend on your own domain abstraction:
public interface UserRepository {
User findByEmail(String email);
}
@Repository
class JpaUserRepository implements UserRepository { /* the JdbcTemplate lives here */ }
@Service
public class UserService {
private final UserRepository userRepository; // depends on the domain, not the DB
}
The direction of the arrow is the point: business logic points at an interface it owns, and the infrastructure points back at that interface. This is why the repository pattern is the single most useful application of DIP in a Spring Boot app.
There's a real trap on the other side. DIP says "depend on abstractions," and a lot of teams read that as "wrap every service in an interface." The result is a codebase full of UserService / UserServiceImpl pairs where the interface has exactly one implementation:
public interface UserService { /* ... */ }
@Service
public class UserServiceImpl implements UserService { /* the only implementation */ }
This is over-abstraction, and it's its own smell. If there's one implementation, the interface adds a file and an indirection without adding a seam. You introduce the interface when a second implementation appears, or when you need to fake it in tests — not before. For a small application, skipping the interface layer entirely is often the right call. SOLID is a guideline, and blindly applying DIP violates the spirit of the rest of the principles.
The other smells worth knowing
The five principles cover most of it, but a few code smells in Spring Boot projects are common enough to name directly.
| Smell | What it looks like | Fix |
|---|---|---|
| Long method | A method over ~20–30 lines doing several steps | Extract method, or replace with a method object |
| Feature envy | A method that touches another object's data more than its own | Move the method to the object that owns the data |
| Duplicate code | The same validation logic in three controllers | Extract a shared @Component (e.g. an EmailValidator) |
| Message chain | order.getCustomer().getAddress().getCity() | Add a delegating method order.getCustomerCity() |
| Circular dependency | Two services injecting each other, "fixed" with @Lazy | Extract the shared logic into a third service |
Feature envy deserves a line: a ShippingService.calculateCost(Order) that reads mostly Order's fields belongs on Order itself, with the service delegating. The heuristic is simple — if the method spends more time on another object's data than its own, move it. @Lazy and ObjectProvider are worth calling out separately: they usually paper over a circular dependency that should have been broken up into a shared service.
Getting an AI reviewer to actually catch these
Here's a concrete scenario. You've had an AI generate or modify a Spring Boot service, and now you ask it to review the result against SOLID and the smells above. Two things go wrong almost every time.
Vague instructions produce vague reviews. "Check this for SOLID violations" usually comes back as "looks fine, maybe extract a method." The model won't flag the fat service or the growing switch unless you tell it what to look for. Give it the concrete markers from this article: a service over some number of lines or with more than a few injected dependencies; a switch that dispatches on a type string; a repository that throws UnsupportedOperationException; a ServiceImpl whose interface has exactly one implementation.
The AI over-corrects. Say "depend on abstractions" and it will wrap every service in an interface, including the single-implementation ones — reproducing the over-abstraction smell you asked it to remove. Your instructions need to encode the exceptions, not just the rules, or the reviewer becomes part of the problem.
The fix for both is to stop re-explaining this per session and write the rules down once, in a place the reviewer reads every time — a CLAUDE.md or equivalent project instruction file. Something like:
# Spring Boot Review Rules (SOLID & Code Smells)
Flag when reviewing Java/Spring Boot code:
- SRP: a @Service with more than one public business method, or more than 3 injected
dependencies. Propose an extraction.
- OCP: any switch / if-else that dispatches on a type string or enum. Propose the
Map<String, Interface> strategy pattern.
- LSP: any class that throws UnsupportedOperationException from an interface method.
Propose splitting the interface.
- ISP: any interface whose methods aren't all used by every consumer. Propose narrowing.
- DIP: a service depending on a concrete infra type (JdbcTemplate, a concrete client)
instead of an interface. But do NOT introduce an interface when there is only one
implementation.
For each finding, report: the principle violated, the specific lines, and a concrete
before/after refactor. Do not report style nits as principle violations.
The last line matters as much as the list. Without it, the reviewer will pad its output with formatting suggestions and bury the one real finding. Requiring "principle + lines + diff" forces the review to be specific and actionable.
The deeper point is that an AI reviewer is only as good as the rules you give it. It will happily rubber-stamp code when you're vague and over-refactor when you're broad. Encoding the principles — with their thresholds and their exceptions — is what turns it from a vague second opinion into something that catches the fat service before it ships. I wrote more about which of these principles survive the AI-coding era in a separate post.
The Bottom Line
SOLID isn't a checklist to satisfy; it's a set of smells to notice. The question to ask about any class isn't "does it violate principle X" but "how many unrelated reasons does it have to change, and who else is coupled to it."
In Spring Boot terms that usually translates to three habits: split services that have grown too many responsibilities, replace type switches with the container's bean map, and depend on domain interfaces instead of JdbcTemplate — while resisting the urge to add an interface where one isn't needed yet. The principles earn their keep when you can see the smell and reach for the refactor without having to recite the definition first.