Software Engineering Fundamentals46 min read·By Liyabona Saki··

Understanding SOLID Principles in Object-Oriented Design

A complete introduction to the five SOLID principles with Java examples and Spring Boot context — the foundation of clean object-oriented code.

Introduction

In 2002, Robert C. Martin published *Agile Software Development: Principles, Patterns, and Practices* and distilled decades of object-oriented wisdom into five principles that would later be arranged into the acronym SOLID. These aren't theoretical ideals — they are hard-won lessons from production codebases that became impossible to maintain. Every team that ignores them eventually rediscovers why they exist, usually at the worst possible time.

SOLID matters in production because code always changes. Requirements shift, bugs surface, new features are added on top of old ones. A codebase that follows SOLID bends under that pressure instead of breaking. Classes stay small and focused, dependencies point in the right direction, and tests run fast because components are genuinely independent.

In the Spring Boot world, these principles are baked into the framework's DNA. Spring's IoC container IS the Dependency Inversion Principle in action. @Service, @Repository, and @Component beans exist to keep infrastructure details out of your domain logic. Understanding SOLID helps you understand *why* Spring was designed the way it was, and helps you use it more effectively.

Key Takeaways

  • S — Single Responsibility: a class changes for one reason only.
  • O — Open/Closed: extend behavior by adding code, not editing it.
  • L — Liskov Substitution: subclasses must honor the contract of the parent.
  • I — Interface Segregation: clients should not be forced to implement what they don't use.
  • D — Dependency Inversion: high-level modules depend on abstractions, not concrete details.
  • Following all five reduces the cost of change in a codebase dramatically.
  • Spring Boot's architecture is built around these principles, especially D.

S — Single Responsibility Principle

A class should have one, and only one, reason to change. That reason maps to a single *actor* or stakeholder who drives change. When a class mixes concerns from multiple actors, changing it for one actor accidentally breaks behavior for the other.

Violation

java
class Report {
  String generate(Data d)           { /* builds PDF */ }
  void   save(String pdf)           { /* writes to disk */ }
  void   email(String pdf, String to){ /* sends SMTP mail */ }
}

The accounting team changes generate(). The ops team changes save(). The marketing team changes email(). Three reasons to change — three hidden dependencies.

Refactored

```java
@Component class ReportGenerator  { String generate(Data d)            { /* ... */ } }
@Component class ReportStorage    { void save(String pdf)              { /* ... */ } }
@Component class ReportMailer     { void email(String pdf, String to)  { /* ... */ } }

@Service @RequiredArgsConstructor class ReportService { private final ReportGenerator generator; private final ReportStorage storage; private final ReportMailer mailer;

public void publish(Data d, String to) { String pdf = generator.generate(d); storage.save(pdf); mailer.email(pdf, to); } } ```

Each class has exactly one reason to change. Tests are trivial — inject a mock, call the method, verify.

O — Open/Closed Principle

Software should be open for extension but closed for modification. Once a class is tested and in production, you should be able to add new behavior by *adding* code, not by editing the existing class.

Violation

java
double calculateFee(Payment p) {
  if (p.type.equals("CARD"))   return p.amount * 1.029;
  if (p.type.equals("PAYPAL")) return p.amount * 1.034;
  if (p.type.equals("CRYPTO")) return p.amount * 1.05;
  throw new IllegalArgumentException("Unknown type");
}

Every new payment provider requires editing this method, re-testing the whole block, and risking regression.

Refactored with Spring Component List Injection

```java
public interface FeeCalculator {
  double fee(double amount);
  String type();
}

@Component public class CardFeeCalculator implements FeeCalculator { public double fee(double amount) { return amount * 1.029; } public String type() { return "CARD"; } }

@Component public class PaypalFeeCalculator implements FeeCalculator { public double fee(double amount) { return amount * 1.034; } public String type() { return "PAYPAL"; } }

@Service public class FeeService { private final Map<String, FeeCalculator> calculators;

public FeeService(List<FeeCalculator> all) { this.calculators = all.stream() .collect(Collectors.toMap(FeeCalculator::type, c -> c)); }

public double calculate(Payment p) { FeeCalculator calc = calculators.get(p.type); if (calc == null) throw new IllegalArgumentException("Unknown: " + p.type); return calc.fee(p.amount); } } ```

Adding Apple Pay means one new @Component — nothing else changes.

L — Liskov Substitution Principle

If S is a subtype of T, then objects of type T may be replaced with objects of type S without breaking correctness. Practically: a subclass must honor every promise the parent class makes.

Classic Violation

```java
class Rectangle {
  protected int width, height;
  public void setWidth(int w)  { this.width  = w; }
  public void setHeight(int h) { this.height = h; }
  public int area()            { return width * height; }
}

class Square extends Rectangle { @Override public void setWidth(int w) { this.width = w; this.height = w; } @Override public void setHeight(int h) { this.height = h; this.width = h; } } ```

Caller contract: "setWidth and setHeight are independent." Square breaks it.

Fix

Don't inherit for code reuse when the behavioral contract cannot be honored. Model with an interface:

java
interface Shape { int area(); }
record Rectangle(int width, int height) implements Shape { public int area(){return width*height;} }
record Square(int side) implements Shape { public int area(){return side*side;} }

Spring Boot LSP Example

```java
public interface OrderRepository {
  Order save(Order order);  // contract: always returns a saved, non-null Order
  Optional<Order> findById(long id);
}

@Repository public class JpaOrderRepository implements OrderRepository { /* standard JPA */ }

@Repository public class CachedOrderRepository implements OrderRepository { private final JpaOrderRepository delegate; private final Cache cache;

@Override public Order save(Order order) { Order saved = delegate.save(order); cache.put(saved.getId(), saved); // always delegates; never throws new exceptions return saved; } } ```

The cached version adds behavior without weakening the contract.

I — Interface Segregation Principle

No client should be forced to depend on methods it does not use. Fat interfaces create artificial coupling between unrelated clients.

Violation

java
interface UserService {
  User register(RegisterRequest req);
  void changePassword(long userId, String newPassword);
  List<Order> getOrderHistory(long userId);
  void updateShippingAddress(long userId, Address addr);
  List<Invoice> getBillingHistory(long userId);
  void processRefund(long userId, long orderId);
}

The checkout flow depends on processRefund(). The profile page depends on updateShippingAddress(). Both clients drag in methods they don't use.

Segregated

```java
public interface UserRegistration  { User register(RegisterRequest req); }
public interface UserSecurity      { void changePassword(long userId, String newPwd); }
public interface UserOrderHistory  { List<Order> getOrderHistory(long userId); }
public interface UserBilling       { List<Invoice> getBillingHistory(long userId); void processRefund(long userId, long orderId); }

@Service public class UserServiceImpl implements UserRegistration, UserSecurity, UserOrderHistory, UserBilling { /* ... */ } ```

Each consumer only sees the interface it needs.

D — Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details.

Violation

```java
@Service
public class OrderService {
  // Directly creating a low-level detail
  private final MySqlOrderDao dao = new MySqlOrderDao("jdbc:mysql://localhost/orders");

public void place(Order o) { dao.insert(o); // welded to MySQL } } ```

With DIP

```java
public interface OrderRepository { void save(Order o); }

@Repository public class JpaOrderRepository implements OrderRepository { private final EntityManager em; JpaOrderRepository(EntityManager em) { this.em = em; } public void save(Order o) { em.persist(o); } }

@Service public class OrderService { private final OrderRepository repo; OrderService(OrderRepository repo) { this.repo = repo; } // injected by Spring

public void place(Order o) { repo.save(o); } } ```

The OrderService knows about the abstraction. The concrete JpaOrderRepository provides the implementation. Swapping to Redis or an in-memory store for tests costs zero.

Putting It All Together: A SOLID Spring Boot Service

```java
// SRP: one responsibility — process checkouts
// OCP: fee calculation extended without modifying this class
// LSP: OrderRepository contract honored by any impl
// ISP: only the interfaces this service needs are injected
// DIP: depends on abstractions, not JPA or SMTP
@Service
@RequiredArgsConstructor
public class CheckoutService {
  private final CartTotalCalculator calculator;  // SRP + DIP
  private final FeeService          feeService;  // OCP via strategy
  private final OrderRepository     orders;      // DIP + LSP
  private final OrderConfirmation   mailer;      // ISP: only confirm()

public Order checkout(Cart cart, String paymentType) { double subtotal = calculator.total(cart); double fee = feeService.calculate(new Payment(paymentType, subtotal)); Order order = orders.save(new Order(cart.userId(), subtotal + fee)); mailer.confirm(order); return order; } } ```

The class reads like English. Every collaborator can be replaced in tests. Every line has one purpose.

Common Mistakes When Learning SOLID

Over-abstracting too early. DIP doesn't mean every class needs an interface. Add an interface when you have a second implementation (tests, feature flags, alternate DB). Don't create UserServiceInterface when there is only one UserService.

Confusing SRP with one-method-per-class. SRP is about reasons to change, not line count. A TaxCalculator with five tax-related methods has one responsibility.

Creating fat interfaces and calling it ISP. Splitting a 20-method interface into four 5-method interfaces is still bad if no client actually uses all five. Split by client, not by number.

Violating LSP through exception widening. If the parent declares Optional<User> find(long id), a subclass that throws IllegalStateException on a cache miss has violated the contract even if the signature compiles.

Production Best Practices

  • Apply SRP at the method level too: a method over 20 lines is usually doing two things.
  • Use Spring's @ConditionalOnProperty or profiles to swap DIP implementations without code changes.
  • Write unit tests before refactoring to SOLID — they act as your safety net.
  • Use ArchUnit to enforce that *.service packages don't import *.infrastructure packages directly.
  • Introduce interfaces incrementally. Start with the seam that hurts most in tests.

FAQ

Q: Do I have to follow all five principles at once? No. Start with SRP and DIP — they deliver the most immediate benefit in testability. LSP, OCP, and ISP become more important as the codebase grows and teams multiply.

Q: Does Spring enforce SOLID automatically? Spring encourages DIP via dependency injection, but it doesn't prevent you from writing God classes or fat interfaces. The discipline has to come from the team.

Q: What is the relationship between SOLID and Clean Architecture? Clean Architecture (Uncle Bob's later work) is essentially what you get when you apply all five SOLID principles at the architectural level. DIP becomes the boundary between layers; SRP determines how to slice layers; OCP governs how plugins attach to the core.

Related Tutorials

What this guide consolidates

The five SOLID principles each used to live on a short standalone page. They are better read together — refactoring a real codebase usually applies several at once — so the per-principle pages have been merged into this single, longer reference.

Single Responsibility Principle (SRP) Explained with Java Examples

Introduction

The Single Responsibility Principle (SRP) is the most immediately practical of the five SOLID principles. It states: a class should have one, and only one, reason to change. That reason maps to a specific *actor* — a person or team whose requirements drive the change. When a class serves multiple actors, a change for one actor accidentally breaks behavior that another depends on.

In production Spring Boot codebases, SRP violations are the single biggest source of merge conflicts, regression bugs, and slow test suites. A monolithic UserService that handles registration, authentication, password reset, profile management, billing, and audit logging becomes a permanent bottleneck — every team touches it for every feature, so it is always in a conflicted state and never truly stable.

SRP is not about line count. A class with 300 lines that encapsulates one cohesive responsibility is fine. A 30-line class mixing HTTP parsing, database writes, and email sending is a violation. The key question is: how many distinct actors would independently request changes to this class? If more than one, it has too many responsibilities.

The Problem It Solves

Here is a classic violation mixing concerns from three different teams:

```java
// THREE reasons to change — three teams own different pieces
public class Report {
  // Analytics team controls this
  public String generate(Data d) {
    StringBuilder sb = new StringBuilder();
    sb.append("<html><body>");
    d.rows().forEach(r -> sb.append("<tr><td>").append(r).append("</td></tr>"));
    sb.append("</body></html>");
    return sb.toString();
  }

// Ops/infra team controls this public void save(String filename, String content) throws IOException { Files.writeString(Path.of("/var/reports/" + filename), content); }

// Communications team controls this public void email(String content, String to) { Session session = Session.getDefaultInstance(smtpProps()); // ... build and send MimeMessage via SMTP } } ```

When the analytics team adds charts to generate(), the ops team is dragged into the code review. When the comms team migrates to SendGrid, database tests fail because the test context changed. Three actors, three coupling points, one file that is always blocked.

The Solution

Refactor so each class has exactly one reason to change:

```java
// One reason to change: how reports are generated (analytics team)
@Component
public class ReportGenerator {
  public String generate(Data d) {
    StringBuilder sb = new StringBuilder();
    sb.append("<html><body>");
    d.rows().forEach(r -> sb.append("<tr><td>").append(r).append("</td></tr>"));
    sb.append("</body></html>");
    return sb.toString();
  }
}

// One reason to change: how reports are stored (ops/infra team) @Component public class ReportStorage { private final Path basePath;

public ReportStorage(@Value("${reports.dir}") String dir) { this.basePath = Path.of(dir); }

public void save(String filename, String content) throws IOException { Files.writeString(basePath.resolve(filename), content, StandardOpenOption.CREATE_NEW); } }

// One reason to change: how reports are delivered (comms team) @Component public class ReportMailer { private final JavaMailSender mailSender;

public ReportMailer(JavaMailSender mailSender) { this.mailSender = mailSender; }

public void email(String content, String to) { SimpleMailMessage msg = new SimpleMailMessage(); msg.setTo(to); msg.setSubject("Your Report"); msg.setText(content); mailSender.send(msg); } }

// Thin orchestrator — coordinates, owns no logic @Service @RequiredArgsConstructor public class ReportService { private final ReportGenerator generator; private final ReportStorage storage; private final ReportMailer mailer;

public void publishReport(Data d, String filename, String to) throws IOException { String content = generator.generate(d); storage.save(filename, content); mailer.email(content, to); } } ```

Three separate git touchpoints. Three separate test files. Changes from one team never accidentally break another.

Spring Boot Real-World Example

The most common SRP violation in enterprise Spring Boot projects is the overloaded UserService:

java
// VIOLATION: 7 reasons to change across 5 teams
@Service
public class UserService {
  void register(RegisterRequest req)        { /* hashes pwd, saves */ }
  UserDetails loadUserByUsername(String u)  { /* Spring Security */ }
  void changePassword(long id, String pwd)  { /* validates + saves */ }
  void forgotPassword(String email)         { /* sends reset email */ }
  void updateProfile(long id, Profile p)    { /* name, avatar, bio */ }
  void deactivate(long id)                  { /* soft-delete */ }
  List<AuditEvent> getAuditLog(long id)     { /* compliance reads */ }
}

Split by actor:

```java
// Actor: onboarding / registration team
@Service
public class UserRegistrationService {
  private final UserRepository users;
  private final PasswordEncoder encoder;
  private final ApplicationEventPublisher events;

public User register(RegisterRequest req) { if (users.existsByEmail(req.email())) { throw new DuplicateEmailException(req.email()); } User user = new User(req.email(), encoder.encode(req.password())); User saved = users.save(user); events.publishEvent(new UserRegisteredEvent(saved.getId())); return saved; } }

// Actor: Spring Security / auth team @Service public class UserSecurityService implements UserDetailsService { private final UserRepository users; private final PasswordEncoder encoder; private final EmailService emailService;

@Override public UserDetails loadUserByUsername(String email) { return users.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException(email)); }

public void changePassword(long userId, String newPassword) { User user = users.findById(userId).orElseThrow(); user.setPassword(encoder.encode(newPassword)); users.save(user); }

public void forgotPassword(String email) { users.findByEmail(email).ifPresent(user -> { String token = UUID.randomUUID().toString(); user.setPasswordResetToken(token); users.save(user); emailService.sendPasswordReset(email, token); }); } }

// Actor: profile / settings team @Service public class UserProfileService { private final UserRepository users;

public void updateProfile(long userId, Profile profile) { User user = users.findById(userId).orElseThrow(); user.setDisplayName(profile.displayName()); user.setBio(profile.bio()); users.save(user); }

public void deactivate(long userId) { User user = users.findById(userId).orElseThrow(); user.setActive(false); users.save(user); } }

// Actor: compliance / audit team @Service public class UserAuditService { private final AuditRepository audits;

public List<AuditEvent> getAuditLog(long userId) { return audits.findByUserId(userId, Sort.by("createdAt").descending()); } } ```

Four independent services. Four separate test files. Four separate deployment touchpoints.

Step-by-Step Implementation Guide

Step 1: List responsibilities. Write down everything the class does. Use "AND" between items — each AND is a potential SRP boundary.

Step 2: Identify the actor for each. Ask who would request a change. Registration team, auth team, profile team, compliance team — each is a distinct actor.

Step 3: Create focused classes. One class per actor group. Name them after what they *do*: ReportGenerator, ReportStorage, ReportMailer. Avoid vague suffixes like Manager, Helper, or Util.

Step 4: Write a thin orchestrator. When responsibilities must be coordinated, a thin service delegates to them in sequence. The orchestrator has no business logic — it only calls other services.

Step 5: Move tests first. Before refactoring, ensure existing tests pass. After splitting, move each responsibility's tests to the appropriate new test file.

Step 6: Clean up dead code. With focused classes, unused imports and orphaned private methods become obvious.

Common Mistakes Developers Make

Confusing SRP with one-method-per-class. SRP is about actors, not line count. TaxCalculator can have calculateVat(), calculateSalesTax(), and calculateWithholding() — they all serve the same tax-domain actor.

Over-splitting. Creating UserSaver, UserValidator, UserEncoder as separate classes for a single operation is over-engineering. Group by actor, not by method.

Letting the orchestrator grow fat. The ReportService above must stay thin. The moment it starts making decisions, it has acquired a responsibility it should not have.

Applying SRP only to classes. Method-level SRP is equally important. A method over 20 lines almost always does two things. Extract private methods when a public method has multiple conceptual steps.

Skipping tests before refactoring. SRP refactoring moves code, not logic. Tests are your safety net — write them first.

How to Test This Principle

```java
// ReportGenerator: zero mocks needed
@Test
void generatesHtmlWithAllRows() {
  Data data = new Data(List.of("Alpha", "Beta", "Gamma"));
  String html = new ReportGenerator().generate(data);
  assertThat(html).contains("Alpha").contains("Beta").contains("Gamma");
}

// ReportStorage: uses real temp directory @Test void savesContentToDirectory(@TempDir Path tmp) throws IOException { ReportStorage storage = new ReportStorage(tmp.toString()); storage.save("report.html", "<html>test</html>"); assertThat(Files.readString(tmp.resolve("report.html"))) .isEqualTo("<html>test</html>"); }

// ReportMailer: one mock @Test void emailsToCorrectRecipient() { JavaMailSender sender = mock(JavaMailSender.class); new ReportMailer(sender).email("<html/>", "user@example.com"); ArgumentCaptor<SimpleMailMessage> cap = ArgumentCaptor.forClass(SimpleMailMessage.class); verify(sender).send(cap.capture()); assertThat(cap.getValue().getTo()).contains("user@example.com"); }

// ReportService: three mocks, each interaction verified @Test void publishDelegatesAllCollaborators() throws IOException { ReportGenerator gen = mock(ReportGenerator.class); ReportStorage storage = mock(ReportStorage.class); ReportMailer mailer = mock(ReportMailer.class); Data data = new Data(List.of()); when(gen.generate(data)).thenReturn("<html/>");

new ReportService(gen, storage, mailer) .publishReport(data, "r.html", "a@b.com");

verify(gen).generate(data); verify(storage).save("r.html", "<html/>"); verify(mailer).email("<html/>", "a@b.com"); verifyNoMoreInteractions(gen, storage, mailer); } ```

Interview Questions and Answers

Q1: What does "one reason to change" actually mean?

It means one *actor* — one team or stakeholder — drives changes to the class. If the analytics team, ops team, and comms team all independently submit PRs touching the same class, that class has three reasons to change. The test: can you name distinct groups who would change this class for unrelated reasons? If yes, split it.

Q2: How do you find SRP violations in a large legacy codebase?

Check git log — if one file appears in PRs from unrelated feature branches, it serves multiple actors. Check imports — a class importing both javax.mail and java.sql serves two infrastructure concerns. Check test setup — if instantiating a class requires 5+ mocks, it has too many collaborators.

Q3: Is it always wrong to have a class with more than one public method?

Absolutely not. A class can have dozens of methods if they all serve the same actor and the same conceptual purpose. OrderCalculator with calculateSubtotal(), calculateTax(), calculateShipping(), and calculateTotal() has one responsibility: order calculation. All four methods serve the same domain actor.

Q4: How does SRP affect transaction management in Spring?

@Transactional belongs on the service layer. When a service has a single responsibility, transaction boundaries are clear — the entire service method is one atomic unit of work. Mixed services create transactions spanning unrelated operations, causing unnecessary locking and difficult rollback semantics.

Key Takeaways

  • SRP is violated when more than one actor independently drives changes to the same class.
  • The fix is to split by actor and wire them through a thin orchestrator.
  • SRP applies at the method level too — methods over 20 lines usually have two jobs.
  • Classes with one responsibility test with minimal setup: one collaborator = one mock at most.
  • Over-splitting (one class per method) trades one problem for another — group by actor.
  • Spring Boot encourages SRP through its layered annotations: @Service, @Repository, @Component.
  • The real cost of SRP violations is paid every sprint, in merge conflicts and regression bugs.

Related Tutorials

Open/Closed Principle (OCP) in Real-World Spring Boot Applications

Introduction

The Open/Closed Principle states: software entities should be open for extension, but closed for modification. Bertrand Meyer coined it in 1988; Robert C. Martin refined it in the context of polymorphism. The core idea is that once a class is tested and shipped, you should be able to add new behavior without touching it. This keeps the risk surface of every change small.

In production, OCP is most valuable at *variation points* — places in your code where behavior is likely to grow. Payment gateways, notification channels, report formats, pricing engines, discount rules: these are all known variation points. If you model them as a growing if/else chain or switch statement, every new variant becomes a regression risk for every existing variant.

OCP doesn't mean pre-abstracting everything. Premature abstraction creates complexity without benefit. Apply OCP when you see a real pattern of variation — typically when you already have two or three variants and a third is on the roadmap.

Key Takeaways

  • Open/Closed means new behavior is added by writing new code, not editing old code.
  • Polymorphism (interface + implementations) is the primary mechanism.
  • Spring Boot's component list injection makes OCP effortless at the service layer.
  • Apply at known variation points; don't pre-abstract everything.
  • OCP reduces the regression risk of adding new variants.

The Classic Smell: Growing Switch Statements

java
// Every new payment type requires editing this method
double calculateFee(Payment p) {
  switch (p.type) {
    case "CARD":    return p.amount * 1.029;
    case "PAYPAL":  return p.amount * 1.034;
    case "CRYPTO":  return p.amount * 1.05;
    default: throw new IllegalArgumentException("Unknown: " + p.type);
  }
}

When Apple Pay launches you edit this method. When Klarna launches you edit it again. Each edit risks breaking the three existing cases. The method has too many reasons to change.

Refactored with Polymorphism

```java
public interface FeeCalculator {
  double fee(double amount);
  String paymentType();
}

@Component public class CardFeeCalculator implements FeeCalculator { public double fee(double amount) { return amount * 1.029; } public String paymentType() { return "CARD"; } }

@Component public class PaypalFeeCalculator implements FeeCalculator { public double fee(double amount) { return amount * 1.034; } public String paymentType() { return "PAYPAL"; } }

@Component public class CryptoFeeCalculator implements FeeCalculator { public double fee(double amount) { return amount * 1.05; } public String paymentType() { return "CRYPTO"; } } ```

Spring collects all FeeCalculator beans into a List<FeeCalculator> via constructor injection:

```java
@Service
public class FeeService {
  private final Map<String, FeeCalculator> calculators;

public FeeService(List<FeeCalculator> all) { this.calculators = all.stream() .collect(Collectors.toMap(FeeCalculator::paymentType, c -> c)); }

public double calculate(Payment p) { FeeCalculator calc = calculators.get(p.type); if (calc == null) throw new UnsupportedOperationException("No calculator for: " + p.type); return calc.fee(p.amount); } } ```

Adding Apple Pay:

java
@Component
public class ApplePayFeeCalculator implements FeeCalculator {
  public double fee(double amount)  { return amount * 1.019; }
  public String paymentType()       { return "APPLE_PAY"; }
}

One new file. Zero edits to FeeService. Zero regression risk to existing calculators.

Real Use Case: Report Generator

```java
public interface ReportExporter {
  byte[] export(ReportData data);
  String format();
}

@Component public class PdfReportExporter implements ReportExporter { public byte[] export(ReportData data) { /* iText/Apache PDFBox */ return new byte[0]; } public String format() { return "PDF"; } }

@Component public class CsvReportExporter implements ReportExporter { public byte[] export(ReportData data) { /* OpenCSV */ return new byte[0]; } public String format() { return "CSV"; } }

@Service public class ReportService { private final Map<String, ReportExporter> exporters;

public ReportService(List<ReportExporter> all) { this.exporters = all.stream().collect(Collectors.toMap(ReportExporter::format, e -> e)); }

public byte[] export(ReportData data, String format) { return Optional.ofNullable(exporters.get(format)) .orElseThrow(() -> new IllegalArgumentException("Format not supported: " + format)) .export(data); } } ```

Adding Excel export later is one new @Component. The service is closed for modification.

Real Use Case: Pricing Engine

```java
public interface PricingRule {
  boolean applies(Order order);
  double apply(double price, Order order);
}

@Component public class SeasonalDiscountRule implements PricingRule { public boolean applies(Order order) { return LocalDate.now().getMonthValue() == 12; } public double apply(double price, Order order) { return price * 0.9; } }

@Component public class LoyaltyDiscountRule implements PricingRule { public boolean applies(Order order) { return order.customer().loyaltyPoints() > 1000; } public double apply(double price, Order order) { return price * 0.95; } }

@Service public class PricingService { private final List<PricingRule> rules; public PricingService(List<PricingRule> rules) { this.rules = rules; }

public double price(Order order) { double price = order.basePrice(); for (PricingRule rule : rules) { if (rule.applies(order)) price = rule.apply(price, order); } return price; } } ```

New promotion types are new classes. The PricingService is never touched.

Notification Channels

```java
public interface NotificationSender {
  void send(Notification n);
  NotificationChannel channel();
}

@Component class EmailSender implements NotificationSender { public void send(Notification n) { /* SMTP */ } public NotificationChannel channel() { return NotificationChannel.EMAIL; } }

@Component class SmsSender implements NotificationSender { public void send(Notification n) { /* Twilio */ } public NotificationChannel channel() { return NotificationChannel.SMS; } }

@Service public class NotificationDispatcher { private final Map<NotificationChannel, NotificationSender> senders; public NotificationDispatcher(List<NotificationSender> all) { this.senders = all.stream().collect(Collectors.toMap(NotificationSender::channel, s -> s)); } public void dispatch(Notification n) { senders.getOrDefault(n.channel(), s -> {}).send(n); } } ```

Testing OCP-Compliant Code

Because each strategy is a small, focused class, tests are trivial:

```java
@Test
void cardFeeIsCorrect() {
  assertThat(new CardFeeCalculator().fee(100.0)).isEqualTo(102.9);
}

@Test void feeServiceRoutesToCorrectCalculator() { FeeCalculator card = mock(FeeCalculator.class); FeeCalculator paypal = mock(FeeCalculator.class); when(card.paymentType()).thenReturn("CARD"); when(paypal.paymentType()).thenReturn("PAYPAL"); when(card.fee(100.0)).thenReturn(102.9);

FeeService svc = new FeeService(List.of(card, paypal)); assertThat(svc.calculate(new Payment("CARD", 100.0))).isEqualTo(102.9); verify(paypal, never()).fee(anyDouble()); } ```

Common OCP Mistakes

Pre-abstracting stable code. Don't add a strategy interface to code that has never changed and has no planned variants. OCP has a real cost: more files, more indirection.

Not registering all variants. If a new @Component isn't picked up (wrong package scan, missing annotation), the service silently falls to a default or throws at runtime. Write an integration test that verifies all expected strategies are registered.

Using inheritance instead of composition. Extending an abstract class to override one method couples the new variant to the entire parent. Prefer implementing a small interface.

Production Best Practices

  • Use @Order or Ordered to control evaluation priority when multiple strategies can apply.
  • Add a canHandle() or applies() method so strategies self-select, removing the dispatch logic from the service.
  • Log which strategy is selected in debug builds to make production behavior traceable.
  • Test the "unknown type" path explicitly — it's often forgotten and explodes in prod.

FAQ

Q: How do I handle validation that varies by type? Same pattern: interface ValidationStrategy { boolean validate(Request req); String type(); }. Each type ships its own validator as a @Component.

Q: What if two strategies both apply? Decide on a composition rule: first-wins, last-wins, reduce, or chain. Make it explicit in the service. The PricingRule example above applies all matching rules in order.

Q: OCP says never modify. But I need to fix a bug in an existing strategy. Fixing a bug is fine — you're honoring the original contract, not changing it. OCP guards against *extending behavior by editing*. A bug fix that restores correct behavior is always appropriate.

Related Tutorials

Liskov Substitution Principle (LSP) Explained Simply

Introduction

Barbara Liskov formally defined her substitution principle in 1987: *if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program*. In plain terms: a subclass must behave like its parent class. Callers that hold a reference to the parent type should not need to know or care which subclass they're actually dealing with.

LSP violations are insidious because they compile cleanly. The type system says everything is fine. But at runtime, a caller that passes a Square where a Rectangle is expected gets wrong answers. Or a service that uses a CachedRepository gets exceptions that never appeared with the plain JpaRepository.

In Spring Boot, LSP violations most often appear in repository hierarchies (caching layers that change exception semantics), authentication strategy chains (providers that throw instead of returning empty), and decorator patterns (wrappers that silently drop writes).

Key Takeaways

  • A subtype must honor every behavioral contract of its parent.
  • Preconditions cannot be strengthened (accept at least as much as the parent).
  • Postconditions cannot be weakened (return at least as much as the parent promises).
  • New unchecked exceptions not in the parent's contract are LSP violations.
  • Prefer interfaces or composition when inheritance would force an LSP violation.

The Classic Violation: Square and Rectangle

```java
class Rectangle {
  protected int width, height;
  public void setWidth(int w)  { this.width  = w; }
  public void setHeight(int h) { this.height = h; }
  public int area()            { return width * height; }
}

class Square extends Rectangle { // Enforces square invariant @Override public void setWidth(int w) { this.width = w; this.height = w; } @Override public void setHeight(int h) { this.height = h; this.width = h; } } ```

Now consider this test:

```java
void assertArea20(Rectangle r) {
  r.setWidth(5);
  r.setHeight(4);
  // Caller assumes width and height are independent
  assert r.area() == 20 : "Expected 20, got " + r.area();
}

assertArea20(new Rectangle()); // passes assertArea20(new Square()); // FAILS: area = 16 (Square.setHeight coupled width to 4) ```

The contract "width and height are independent settable dimensions" is broken by Square.

Correct Solution

```java
public interface Shape { int area(); }

public record Rectangle(int width, int height) implements Shape { public int area() { return width * height; } }

public record Square(int side) implements Shape { public int area() { return side * side; } } ```

No inheritance. No broken contracts. Each type is independently correct.

Spring Boot: Repository Hierarchies

A caching decorator that violates LSP:

```java
// Parent contract: findById returns the user if found, Optional.empty() if not.
public interface UserRepository extends JpaRepository<User, Long> {
  Optional<User> findById(Long id);
}

// Violates LSP: throws a new exception not in the contract @Repository public class CachedUserRepository implements UserRepository { private final UserRepository delegate; private final RedisTemplate<String, User> redis;

@Override public Optional<User> findById(Long id) { String key = "user:" + id; // BUG: throws if Redis is down instead of delegating User cached = redis.opsForValue().get(key); if (cached != null) return Optional.of(cached); return delegate.findById(id); } } ```

Callers expect Optional<User>, never an exception for a simple lookup. Fix it:

java
@Override
public Optional<User> findById(Long id) {
  String key = "user:" + id;
  try {
    User cached = redis.opsForValue().get(key);
    if (cached != null) return Optional.of(cached);
  } catch (Exception e) {
    log.warn("Redis unavailable, falling back to DB", e);
  }
  // Always honor the contract
  return delegate.findById(id);
}

Spring Boot: Authentication Strategy

```java
public interface AuthProvider {
  // Contract: returns authenticated token or Optional.empty() — never throws
  Optional<Authentication> authenticate(Credentials creds);
}

@Component public class JwtAuthProvider implements AuthProvider { @Override public Optional<Authentication> authenticate(Credentials creds) { try { JwtClaims claims = jwtParser.parse(creds.token()); return Optional.of(new JwtAuthentication(claims)); } catch (InvalidJwtException e) { return Optional.empty(); // honors contract } } }

@Component public class ApiKeyAuthProvider implements AuthProvider { @Override public Optional<Authentication> authenticate(Credentials creds) { return apiKeyRepository.findByKey(creds.token()) .map(ApiKeyAuthentication::new); // honors contract } }

@Service public class AuthService { private final List<AuthProvider> providers;

public Authentication authenticate(Credentials creds) { return providers.stream() .map(p -> p.authenticate(creds)) .filter(Optional::isPresent) .map(Optional::get) .findFirst() .orElseThrow(() -> new AuthenticationException("Invalid credentials")); } } ```

Because every AuthProvider honors the contract, the AuthService can iterate safely without try/catch around each.

Design-by-Contract Concepts

LSP is the behavioral formalization of Design by Contract:

  • Preconditions: conditions the caller must satisfy before calling. A subclass cannot add new preconditions (it must accept at least as much as the parent).
  • Postconditions: guarantees the method makes to the caller. A subclass cannot weaken them (it must provide at least as much as the parent promised).
  • Invariants: conditions that must always hold for the object. A subclass cannot break parent invariants.
```java
// Parent postcondition: returned list is non-null and sorted ascending
List<Order> findRecentOrders(long userId);

// LSP VIOLATION: returns null instead of empty list @Override public List<Order> findRecentOrders(long userId) { List<Order> result = cache.get(userId); return result; // null if cache miss — weakened postcondition }

// Correct @Override public List<Order> findRecentOrders(long userId) { List<Order> cached = cache.get(userId); return cached != null ? cached : delegate.findRecentOrders(userId); } ```

Testing LSP Compliance

A practical approach: write a shared contract test and run it against every implementation:

```java
public abstract class UserRepositoryContractTest {
  protected abstract UserRepository createRepository();

@Test void findByIdReturnsEmptyForUnknownUser() { assertThat(createRepository().findById(9999L)).isEmpty(); }

@Test void findByIdNeverThrowsForAnyId() { assertThatNoException().isThrownBy(() -> createRepository().findById(-1L)); }

@Test void savedUserIsRetrievable() { UserRepository repo = createRepository(); User saved = repo.save(new User("alice")); assertThat(repo.findById(saved.getId())).isPresent(); } }

class JpaUserRepositoryTest extends UserRepositoryContractTest { @Override protected UserRepository createRepository() { return new JpaUserRepository(em); } }

class CachedUserRepositoryTest extends UserRepositoryContractTest { @Override protected UserRepository createRepository() { return new CachedUserRepository(new InMemoryUserRepository(), fakeRedis); } } ```

Both implementations must pass the same contract tests. If CachedUserRepository fails any of them, LSP is violated.

Common LSP Pitfalls

Throwing new runtime exceptions. If the parent never throws UnsupportedOperationException for a given method, neither should the child.

Returning null where the parent returned empty Optional. A defensive null check in the parent's caller won't be written because the parent never returns null.

Ignoring writes silently. A no-op save() in a read-only adapter that doesn't throw violates the postcondition that data is persisted.

Changing return collection mutability. If the parent returns a mutable list, the subclass must not return Collections.unmodifiableList() if the caller ever adds to it.

Production Best Practices

  • Write contract tests (abstract test base classes) for all interfaces that have multiple implementations.
  • Use @Override always — it guarantees you're actually overriding, not adding a new method.
  • Document behavioral contracts in Javadoc on the interface, not the implementation.
  • When a cache or decorator layer must diverge from the base contract, reconsider the design — possibly the cache belongs in a separate service, not in the repository layer.

FAQ

Q: Does LSP mean I can never override methods? No. You can override freely as long as you honor the contract. Overriding to add caching, logging, or metrics is fine. Overriding to change what the method returns or throw exceptions the parent didn't is not.

Q: Does LSP apply to interfaces? Yes. Every implementation of an interface must honor the interface's behavioral contract, not just its signature.

Q: How does LSP relate to Dependency Inversion? DIP says depend on abstractions. LSP ensures that the abstraction's contract is actually honored, making DIP safe. Without LSP, DIP collapses — your “abstraction” isn’t reliable.

Related Tutorials

Interface Segregation Principle (ISP) with Practical Examples

Introduction

The Interface Segregation Principle states: no client should be forced to depend on methods it does not use. It was introduced by Robert C. Martin in the early 1990s while he was consulting for Xerox. The problem was a Job class with hundreds of methods — printing, stapling, faxing — that every printer driver had to implement even if the printer couldn't staple or fax. The solution was to break the monster interface into smaller, focused ones.

In modern Spring Boot applications, ISP violations usually appear as services or controllers that inject an interface with 10+ methods, but only call two or three of them. This is more than an aesthetic problem: fat interfaces create artificial coupling. When the interface changes — even for methods your class never calls — all implementations must be updated and re-tested. Fat interfaces also make mocking harder in tests, since you must stub dozens of methods to make one test pass.

ISP is closely related to SRP. A fat interface is usually a sign that a class or service is doing too many things for too many actors. The fix is the same: split by client.

Key Takeaways

  • Clients should only see the methods they actually use.
  • Fat interfaces create unnecessary recompilation and re-testing.
  • Split interfaces by *client* — each consumer gets the interface it needs.
  • ISP and ISP violations are visible in test setup: too many mocked methods = ISP violation.
  • In Spring Boot, ISP shapes how you define service contracts for different layers.

Classic Violation: The Fat Worker Interface

```java
interface Worker {
  void work();
  void eat();
  void sleep();
  void attendMeeting();
}

class HumanWorker implements Worker { public void work() { /* ... */ } public void eat() { /* ... */ } public void sleep() { /* ... */ } public void attendMeeting() { /* ... */ } }

class RobotWorker implements Worker { public void work() { /* ... */ } public void eat() { throw new UnsupportedOperationException(); } // violation! public void sleep() { throw new UnsupportedOperationException(); } public void attendMeeting() { /* robots don't attend meetings */ } } ```

RobotWorker is forced to implement methods it can never meaningfully support. This is the ISP smell.

Segregated Interfaces

```java
interface Workable     { void work(); }
interface Feedable     { void eat(); }
interface Resting      { void sleep(); }
interface MeetingGoer  { void attendMeeting(); }

class HumanWorker implements Workable, Feedable, Resting, MeetingGoer { /* ... */ } class RobotWorker implements Workable { /* only what robots actually do */ } ```

Now RobotWorker implements exactly one interface. The WorkScheduler that only needs Workable doesn't know robots exist.

Spring Boot: Notification Service Fully Built Out

A common real-world ISP pattern in Spring Boot applications is the notification system.

The Fat Interface (Violation)

```java
// Every implementer must handle all three channels
public interface NotificationService {
  void sendEmail(String to, String subject, String body);
  void sendSms(String to, String body);
  void sendPush(String deviceId, String title, String body);
  void sendInApp(long userId, String message);
}

@Service public class AlertService { // Injected but only needs SMS private final NotificationService notifications;

public void alertOnLowInventory(Product p) { notifications.sendSms("+15551234567", "Low inventory: " + p.name()); // sendEmail, sendPush, sendInApp never called — dead dependency } } ```

Segregated (Correct)

```java
public interface EmailNotifier {
  void sendEmail(String to, String subject, String body);
}

public interface SmsNotifier { void sendSms(String to, String body); }

public interface PushNotifier { void sendPush(String deviceId, String title, String body); }

public interface InAppNotifier { void sendInApp(long userId, String message); }

// The full implementation wires them all together @Service public class NotificationServiceImpl implements EmailNotifier, SmsNotifier, PushNotifier, InAppNotifier {

private final JavaMailSender mailSender; private final TwilioClient twilio; private final FcmClient fcm; private final InAppRepository inApp;

public NotificationServiceImpl(JavaMailSender mailSender, TwilioClient twilio, FcmClient fcm, InAppRepository inApp) { this.mailSender = mailSender; this.twilio = twilio; this.fcm = fcm; this.inApp = inApp; }

@Override public void sendEmail(String to, String subject, String body) { SimpleMailMessage msg = new SimpleMailMessage(); msg.setTo(to); msg.setSubject(subject); msg.setText(body); mailSender.send(msg); }

@Override public void sendSms(String to, String body) { twilio.messages().create(to, body); }

@Override public void sendPush(String deviceId, String title, String body) { fcm.send(new PushMessage(deviceId, title, body)); }

@Override public void sendInApp(long userId, String message) { inApp.save(new InAppNotification(userId, message)); } } ```

Now each consumer injects only what it needs:

```java
@Service
@RequiredArgsConstructor
public class AlertService {
  private final SmsNotifier smsNotifier; // only SMS

public void alertOnLowInventory(Product p) { smsNotifier.sendSms("+15551234567", "Low inventory: " + p.name()); } }

@Service @RequiredArgsConstructor public class MarketingService { private final EmailNotifier emailNotifier; // only email

public void sendPromotion(String to, String subject, String body) { emailNotifier.sendEmail(to, subject, body); } } ```

User Role-Based Interfaces

Another practical Spring Boot pattern is splitting user operations by role:

```java
public interface CustomerOperations {
  Order placeOrder(Cart cart);
  List<Order> getOrderHistory(long userId);
  void updateShippingAddress(long userId, Address addr);
}

public interface AdminOperations { List<Order> getAllOrders(Pageable page); void cancelOrder(long orderId, String reason); void issueRefund(long orderId, double amount); }

public interface WarehouseOperations { void markShipped(long orderId, String trackingNumber); List<Order> getPendingShipments(); }

@Service public class OrderServiceImpl implements CustomerOperations, AdminOperations, WarehouseOperations { // full implementation } ```

The customer REST controller injects CustomerOperations. The admin panel injects AdminOperations. Neither can accidentally call each other's methods.

Testing Benefits of ISP

Fat interfaces make mocking tedious:

java
// Fat interface — 4 methods to stub for a 1-method test
NotificationService mockNotifications = mock(NotificationService.class);
doNothing().when(mockNotifications).sendEmail(any(), any(), any());
doNothing().when(mockNotifications).sendSms(any(), any());
doNothing().when(mockNotifications).sendPush(any(), any(), any());
doNothing().when(mockNotifications).sendInApp(anyLong(), any());

With ISP:

java
// Clean — one interface, one method
SmsNotifier mockSms = mock(SmsNotifier.class);
new AlertService(mockSms).alertOnLowInventory(product);
verify(mockSms).sendSms(eq("+15551234567"), contains("Low inventory"));

Tests are faster to write, easier to read, and more precisely targeted.

ISP and Microservices API Design

ISP applies at the API level in microservices too. Instead of one massive gRPC service definition or REST API that all consumers call, use separate endpoints or service contracts per consumer.

Consumer-Driven Contract Testing (e.g., Pact) is ISP at the service boundary level: each downstream consumer defines what it *needs* from the upstream API, and the upstream ensures that contract is honored — without the downstream being affected by other fields or endpoints it doesn't consume.

Common ISP Violations

| Signal | Likely ISP violation | |---|---| | Mock setup has 5+ when() calls for a 2-line test | Interface too fat | | Implementations throw UnsupportedOperationException | Interface includes methods some implementers can't fulfill | | Interface imported in a class that only uses 1/10 methods | Interface not segregated by consumer | | Service changes break unrelated consumers | Interface mixed concerns |

Production Best Practices

  • Name interfaces by client role, not by implementation: OrderReader, OrderWriter, OrderAdmin rather than OrderServiceInterface.
  • Each Spring @Controller should inject only the narrowest interface it needs.
  • Review interface size at code review: if a new method is added to an interface and requires stubbing in 12 existing tests, the interface is too fat.
  • In hexagonal architecture, ports are ISP interfaces — each external adapter implements only the port it provides.

FAQ

Q: Should I always split an interface if it has more than 3 methods? No, that's too mechanical. Split by client — if all three consumers need all three methods, the interface is fine as-is. Split when different consumers use different subsets.

Q: What if I have only one implementation right now? That's fine. You can still segregate interfaces. The benefit is in the consumers — they compile against smaller contracts and are less affected by future changes.

Q: How does ISP relate to the Facade pattern? A Facade is the opposite direction: it provides one simplified interface in front of many. ISP is about the implementing side — don't force implementers to provide methods they can't meaningfully support.

Related Tutorials

Dependency Inversion Principle (DIP) in Spring Boot

Introduction

The Dependency Inversion Principle (DIP) is the foundation that makes all other SOLID principles work in a production system. It states two things:

1. High-level modules should not depend on low-level modules. Both should depend on abstractions. 2. Abstractions should not depend on details. Details should depend on abstractions.

This sounds abstract, so here's the concrete impact: when your OrderService (high-level) directly instantiates a MySqlOrderDao (low-level), you cannot test OrderService without a real database. You cannot swap databases without editing OrderService. You cannot run the service in a staging environment that uses a different store. Every time MySQL changes, OrderService is a casualty.

DIP inverts this. The OrderService depends only on an OrderRepository interface (an abstraction). The JpaOrderRepository provides the MySQL implementation. Spring wires them together. Now OrderService never knows — and doesn't need to know — that MySQL exists.

This is why Spring Boot exists. Its IoC container *is* the Dependency Inversion Principle applied systematically to an entire application.

Key Takeaways

  • High-level policy (business logic) must not import low-level mechanisms (JPA, Kafka, Redis).
  • The abstraction (interface) belongs to the domain/business layer, not the infrastructure layer.
  • Spring's constructor injection is the right implementation of DIP — use it over field injection.
  • DIP enables test-time fakes without mocking frameworks.
  • Hexagonal architecture (ports & adapters) is DIP applied at the architectural level.

Without DIP: The Problem

```java
// High-level module directly imports a low-level detail
@Service
public class OrderService {
  // Directly creates the implementation — impossible to swap
  private final MySqlOrderDao dao = new MySqlOrderDao(
      "jdbc:mysql://prod-db:3306/orders", "user", "password");

public void place(Order o) { double tax = o.getAmount() * 0.2; // tax logic coupled with persistence dao.insert(o.withTax(tax)); // sends email inline with another new — no abstraction new SmtpEmailSender("smtp.company.com", 587).send(o.customerEmail(), "Order placed"); } } ```

Pain points: - Unit tests require a real MySQL server and SMTP relay. - Changing to PostgreSQL means editing OrderService. - A second caller (batch job) that needs the same logic must duplicate it. - CI builds are slow and brittle.

With DIP: The Solution

```java
// Abstraction lives in the domain layer
public interface OrderRepository {
  Order save(Order order);
  Optional<Order> findById(long id);
  List<Order> findByCustomerId(long customerId);
}

public interface OrderNotifier { void confirmPlacement(Order order); }

// High-level module: depends only on abstractions @Service public class OrderService { private final OrderRepository repository; private final TaxCalculator taxCalculator; private final OrderNotifier notifier;

// Constructor injection — the Spring way public OrderService(OrderRepository repository, TaxCalculator taxCalculator, OrderNotifier notifier) { this.repository = repository; this.taxCalculator = taxCalculator; this.notifier = notifier; }

public Order place(Order order) { double tax = taxCalculator.calculate(order.getAmount()); Order taxed = order.withTax(tax); Order saved = repository.save(taxed); notifier.confirmPlacement(saved); return saved; } } ```

```java
// Low-level module: implements the abstraction
@Repository
public class JpaOrderRepository implements OrderRepository {
  private final OrderJpaRepo jpa;
  public JpaOrderRepository(OrderJpaRepo jpa) { this.jpa = jpa; }

@Override public Order save(Order order) { return jpa.save(order); } @Override public Optional<Order> findById(long id) { return jpa.findById(id); } @Override public List<Order> findByCustomerId(long id){ return jpa.findByCustomerId(id); } }

@Component public class EmailOrderNotifier implements OrderNotifier { private final JavaMailSender mailSender; public EmailOrderNotifier(JavaMailSender mailSender) { this.mailSender = mailSender; }

@Override public void confirmPlacement(Order order) { SimpleMailMessage msg = new SimpleMailMessage(); msg.setTo(order.customerEmail()); msg.setSubject("Order confirmed #" + order.getId()); msg.setText("Your order has been placed."); mailSender.send(msg); } } ```

Multiple Adapter Examples

Redis Adapter for Caching

```java
@Repository
@Primary // Spring uses this when both JPA and Redis are available
public class RedisOrderRepository implements OrderRepository {
  private final OrderRepository  delegate; // JPA
  private final RedisTemplate<String, Order> redis;

public RedisOrderRepository( @Qualifier("jpaOrderRepository") OrderRepository delegate, RedisTemplate<String, Order> redis) { this.delegate = delegate; this.redis = redis; }

@Override public Order save(Order order) { Order saved = delegate.save(order); redis.opsForValue().set("order:" + saved.getId(), saved, Duration.ofMinutes(30)); return saved; }

@Override public Optional<Order> findById(long id) { Order cached = redis.opsForValue().get("order:" + id); if (cached != null) return Optional.of(cached); Optional<Order> found = delegate.findById(id); found.ifPresent(o -> redis.opsForValue().set("order:" + id, o, Duration.ofMinutes(30))); return found; } } ```

Kafka Adapter for Notifications

```java
@Component
public class KafkaOrderNotifier implements OrderNotifier {
  private final KafkaTemplate<String, OrderPlacedEvent> kafka;
  private static final String TOPIC = "order.placed";

public KafkaOrderNotifier(KafkaTemplate<String, OrderPlacedEvent> kafka) { this.kafka = kafka; }

@Override public void confirmPlacement(Order order) { kafka.send(TOPIC, order.getId().toString(), new OrderPlacedEvent(order.getId(), order.customerEmail(), order.getTotal())); } } ```

To switch from email to Kafka notifications, swap the @Component qualifier. OrderService never changes.

Spring Configuration with DIP

```java
@Configuration
public class OrderConfig {

// In production: JPA + Kafka @Bean @Profile("!test") public OrderRepository orderRepository(OrderJpaRepo jpa) { return new JpaOrderRepository(jpa); }

@Bean @Profile("!test") public OrderNotifier orderNotifier(KafkaTemplate<String, OrderPlacedEvent> kafka) { return new KafkaOrderNotifier(kafka); }

// In test: in-memory stubs — no Spring context needed @Bean @Profile("test") public OrderRepository testOrderRepository() { return new InMemoryOrderRepository(); }

@Bean @Profile("test") public OrderNotifier testOrderNotifier() { return order -> {}; // no-op lambda } } ```

Testing with Fakes vs Mocks

DIP makes it possible to test with simple fakes instead of a mocking framework:

```java
// Fake: a real in-memory implementation for tests
public class InMemoryOrderRepository implements OrderRepository {
  private final Map<Long, Order> store = new HashMap<>();
  private long nextId = 1;

@Override public Order save(Order order) { Order withId = order.withId(nextId++); store.put(withId.getId(), withId); return withId; }

@Override public Optional<Order> findById(long id) { return Optional.ofNullable(store.get(id)); }

@Override public List<Order> findByCustomerId(long customerId) { return store.values().stream() .filter(o -> o.getCustomerId() == customerId) .collect(Collectors.toList()); } }

// Test: zero mocking framework needed @Test void placedOrderIsPersistedAndConfirmed() { var repo = new InMemoryOrderRepository(); var notified = new ArrayList<Order>(); var svc = new OrderService(repo, new FixedTaxCalculator(0.2), notified::add);

Order placed = svc.place(new Order(42L, 100.0));

assertThat(placed.getId()).isPositive(); assertThat(placed.getTax()).isEqualTo(20.0); assertThat(repo.findById(placed.getId())).isPresent(); assertThat(notified).hasSize(1); } ```

Fakes are more maintainable than mocks: they don't break when method signatures change slightly, and they test behavior rather than interaction.

Common DIP Violations

Creating dependencies with new inside a service. new EmailSender() inside OrderService means OrderService controls its own wiring — Spring can't help.

Field injection. @Autowired private OrderRepository repo; makes the field invisible to the constructor, so tests can't inject a fake without reflection.

Importing infrastructure packages in domain classes. If your domain/ package imports org.springframework.data.jpa, the dependency arrow is pointing the wrong way.

Interfaces defined in the wrong layer. The OrderRepository interface should live in the domain layer, not the infrastructure layer. If OrderService must import from infrastructure to get the interface type, DIP is violated.

Hexagonal Architecture Connection

Hexagonal architecture (Ports and Adapters) is DIP at the architectural level:

  • Port = interface owned by the domain (e.g., OrderRepository, OrderNotifier).
  • Adapter = infrastructure class that implements the port (e.g., JpaOrderRepository, KafkaOrderNotifier).
  • Domain core = all business logic; zero imports from javax.persistence, org.springframework.kafka, or any infrastructure library.

The rule: dependency arrows always point *inward* toward the domain. Infrastructure depends on the domain, never the reverse.

Production Best Practices

  • Always use constructor injection. It makes dependencies explicit, enables immutability, and doesn't require a Spring context in unit tests.
  • Put interfaces in domain/ or application/ packages. Implementations go in infrastructure/.
  • Use ArchUnit to enforce: noClasses().that().resideInPackage("..domain..").should().dependOnClassesThat().resideInPackage("..infrastructure..") in a CI test.
  • Create in-memory fakes for your main ports. They pay dividends across the entire test suite lifetime.

FAQ

Q: Do I need an interface for every class? No. Create an interface when you have (or will soon have) a second implementation — tests, a different data store, a swap between email and Kafka. Don't add UserServiceInterface if UserServiceImpl is the only implementer and will remain so.

Q: Isn't @Autowired on a field simpler? It's shorter to write, but it hides dependencies, makes tests harder, and prevents the final keyword on fields. Constructor injection is the recommended Spring style and the Spring team has said so explicitly in their documentation.

Q: How is DIP different from Dependency Injection? DIP is the principle (depend on abstractions). Dependency Injection is one technique to implement it (have someone else provide the dependency). Spring's IoC container is a DI framework. You could implement DIP without Spring using manual constructor composition.

Related Tutorials

Refactoring Bad Code Using SOLID Principles

Introduction

Reading about SOLID principles is useful. Watching code transform step by step is better. This article takes a realistic, messy Java service and refactors it to full SOLID compliance in five documented steps. Each step is a standalone commit you could make in a real codebase.

The goal isn't perfection — it's demonstrating how each principle solves a concrete problem that was actually hurting the code.

Key Takeaways

  • SOLID refactoring is incremental, not a rewrite.
  • Each principle addresses a specific pain point.
  • Tests written before refactoring are the safety net.
  • The end result is shorter methods, more files, and dramatically faster tests.
  • A second worked example reinforces the pattern.

Starting Point: The God Service

```java
public class CheckoutService {

public void checkout(Cart cart, String paymentType, String promoCode) { // STEP 1: Calculate total (inline) double total = 0; for (var item : cart.items()) { total += item.price() * item.qty(); }

// STEP 2: Apply promo (inline, grows every sprint) if ("SAVE10".equals(promoCode)) total *= 0.90; if ("SAVE20".equals(promoCode)) total *= 0.80; if ("VIP".equals(promoCode)) total *= 0.70;

// STEP 3: Apply payment fee (inline) if ("CARD".equals(paymentType)) total *= 1.029; else if ("PP".equals(paymentType)) total *= 1.034; else if ("CRYPTO".equals(paymentType)) total *= 1.05;

// STEP 4: Save to DB (raw JDBC) try { var conn = DriverManager.getConnection("jdbc:mysql://prod/orders", "root", "secret"); var ps = conn.prepareStatement("INSERT INTO orders(user_id, total) VALUES (?,?)"); ps.setLong(1, cart.userId()); ps.setDouble(2, total); ps.execute(); } catch (SQLException e) { e.printStackTrace(); // swallowed }

// STEP 5: Send email (inline SMTP) try { var props = new Properties(); props.put("mail.smtp.host", "smtp.company.com"); var session = Session.getInstance(props); var msg = new MimeMessage(session); msg.setRecipients(Message.RecipientType.TO, cart.userEmail()); msg.setSubject("Order confirmed"); msg.setText("Total: " + total); Transport.send(msg); } catch (MessagingException e) { e.printStackTrace(); } } } ```

This violates every SOLID principle: - SRP: five responsibilities in one method. - OCP: adding a new promo code or payment type edits this method. - DIP: depends on JDBC and SMTP concretions directly. - LSP: no subtypes, but the structure prevents any safe extension. - ISP: the class exposes one giant method that future code will copy-paste.

Step 1 — Apply SRP: Extract Responsibilities

Write tests for the current behavior first (integration test with a test database and a fake SMTP server). Then extract each responsibility into its own class.

```java
// Extracted: total calculation
public class CartTotalCalculator {
  public double calculate(Cart cart) {
    return cart.items().stream()
        .mapToDouble(i -> i.price() * i.qty())
        .sum();
  }
}

// Extracted: promo logic public class PromoCodeApplicator { public double apply(double total, String promoCode) { return switch (promoCode) { case "SAVE10" -> total * 0.90; case "SAVE20" -> total * 0.80; case "VIP" -> total * 0.70; default -> total; }; } }

// Extracted: fee logic public class PaymentFeeApplicator { public double apply(double total, String paymentType) { return switch (paymentType) { case "CARD" -> total * 1.029; case "PP" -> total * 1.034; case "CRYPTO" -> total * 1.05; default -> throw new IllegalArgumentException("Unknown: " + paymentType); }; } } ```

The CheckoutService becomes an orchestrator:

```java
public class CheckoutService {
  private final CartTotalCalculator  totals;
  private final PromoCodeApplicator  promos;
  private final PaymentFeeApplicator fees;
  // DB + email still raw for now

public void checkout(Cart cart, String paymentType, String promoCode) { double total = totals.calculate(cart); total = promos.apply(total, promoCode); total = fees.apply(total, paymentType); // ... DB and email below } } ```

Now three tests can be written in milliseconds each, with no database or SMTP.

Step 2 — Apply OCP: Replace Switch Statements with Strategies

The promo switch and payment switch will grow. Replace them with strategy interfaces.

```java
public interface PromoStrategy {
  boolean applies(String code);
  double apply(double total);
}

@Component public class Save10Promo implements PromoStrategy { public boolean applies(String code) { return "SAVE10".equals(code); } public double apply(double total) { return total * 0.90; } }

@Component public class VipPromo implements PromoStrategy { public boolean applies(String code) { return "VIP".equals(code); } public double apply(double total) { return total * 0.70; } }

@Service public class PromoCodeApplicator { private final List<PromoStrategy> strategies; public PromoCodeApplicator(List<PromoStrategy> strategies) { this.strategies = strategies; } public double apply(double total, String code) { return strategies.stream() .filter(s -> s.applies(code)) .findFirst() .map(s -> s.apply(total)) .orElse(total); // no promo = unchanged total } } ```

Do the same for payment fees (already shown in the OCP article). New promos are new @Component classes — PromoCodeApplicator is closed for modification.

Step 3 — Apply DIP: Extract Ports for DB and Email

```java
// Port (interface — belongs to domain layer)
public interface OrderRepository {
  Order save(Order order);
}

// Port public interface OrderConfirmation { void confirm(Order order); }

// Adapter (infrastructure layer) @Repository public class JpaOrderRepository implements OrderRepository { private final EntityManager em; public JpaOrderRepository(EntityManager em) { this.em = em; } @Override public Order save(Order order) { em.persist(order); return order; } }

// Adapter @Component public class EmailOrderConfirmation implements OrderConfirmation { private final JavaMailSender mailSender; public EmailOrderConfirmation(JavaMailSender mailSender) { this.mailSender = mailSender; } @Override public void confirm(Order order) { SimpleMailMessage msg = new SimpleMailMessage(); msg.setTo(order.customerEmail()); msg.setSubject("Order confirmed #" + order.getId()); mailSender.send(msg); } } ```

CheckoutService now takes the interfaces — not the concrete JDBC/SMTP types.

Step 4 — Apply ISP: Narrow the Confirmation Interface

The original OrderConfirmation interface turns out to be used by two consumers: CheckoutService (needs confirm) and RefundService (needs notifyRefund). Split them:

```java
public interface OrderConfirmation { void confirm(Order order); }
public interface RefundNotification { void notifyRefund(Order order, double amount); }

@Component public class EmailOrderNotifier implements OrderConfirmation, RefundNotification { public void confirm(Order order) { /* email */ } public void notifyRefund(Order order, double amount) { /* email */ } } ```

CheckoutService injects OrderConfirmation. RefundService injects RefundNotification. Neither imports methods it doesn't use.

Step 5 — Apply LSP: Write a Contract Test

Now that OrderRepository has two implementations (JpaOrderRepository and a future CachedOrderRepository), write a shared contract test:

```java
public abstract class OrderRepositoryContractTest {
  protected abstract OrderRepository repo();

@Test void savePersistsOrder() { Order saved = repo().save(new Order(1L, 99.99, "user@example.com")); assertThat(saved.getId()).isPositive(); }

@Test void saveNeverReturnsNull() { assertThat(repo().save(new Order(2L, 10.0, "x@x.com"))).isNotNull(); } }

class JpaOrderRepositoryTest extends OrderRepositoryContractTest { @Override protected OrderRepository repo() { return new JpaOrderRepository(testEm()); } }

// Future: CachedOrderRepositoryTest extends OrderRepositoryContractTest ```

The End State

```java
@Service
@RequiredArgsConstructor
public class CheckoutService {
  private final CartTotalCalculator calculator;  // SRP
  private final PromoCodeApplicator promos;      // OCP
  private final PaymentFeeApplicator fees;       // OCP
  private final OrderRepository      repository; // DIP
  private final OrderConfirmation    confirmation; // ISP + DIP

public Order checkout(Cart cart, String paymentType, String promoCode) { double total = calculator.calculate(cart); total = promos.apply(total, promoCode); total = fees.apply(total, paymentType); Order order = repository.save(new Order(cart.userId(), total, cart.userEmail())); confirmation.confirm(order); return order; } } ```

Five lines of business logic. Every collaborator is injected. Every collaborator is swappable. Test time drops from minutes (integration) to milliseconds (unit).

Measuring the Improvement

| Metric | Before | After | |---|---|---| | Lines in checkout method | 45 | 6 | | Dependencies | 0 (all hardcoded) | 5 (all interfaces) | | Tests requiring a DB | all | 0 | | Time to add a new promo | edit checkout + re-test all paths | add 1 class | | Cyclomatic complexity | 8 | 1 |

Second Full Example: Email Campaign Service

Before:

java
public class CampaignService {
  public void send(List<User> users, String template, String channel) {
    for (User u : users) {
      String body = template.replace("{{name}}", u.name());
      if ("EMAIL".equals(channel)) {
        // inline SMTP send
      } else if ("SMS".equals(channel)) {
        // inline Twilio call
      } else if ("PUSH".equals(channel)) {
        // inline FCM call
      }
    }
  }
}

After applying SRP + OCP + DIP:

```java
public interface MessageRenderer { String render(String template, User user); }
public interface CampaignChannel  { void send(User user, String message); String name(); }

@Component class TemplateRenderer implements MessageRenderer { public String render(String template, User user) { return template.replace("{{name}}", user.name()); } }

@Component class EmailChannel implements CampaignChannel { /* ... */ public String name(){return "EMAIL";} } @Component class SmsChannel implements CampaignChannel { /* ... */ public String name(){return "SMS";} }

@Service @RequiredArgsConstructor public class CampaignService { private final MessageRenderer renderer; private final Map<String, CampaignChannel> channels;

public CampaignService(MessageRenderer renderer, List<CampaignChannel> all) { this.renderer = renderer; this.channels = all.stream().collect(Collectors.toMap(CampaignChannel::name, c -> c)); }

public void send(List<User> users, String template, String channelName) { CampaignChannel channel = channels.get(channelName); if (channel == null) throw new IllegalArgumentException("Unknown channel: " + channelName); users.forEach(u -> channel.send(u, renderer.render(template, u))); } } ```

The same transformation: from one god method to focused, testable, extensible collaborators.

Production Best Practices

  • Refactor in small, committed steps. Each step should leave the tests green.
  • Introduce ports (interfaces) at the seam that is hardest to test first.
  • Don't refactor and add features simultaneously — it conflates bugs.
  • Run mutation testing after refactoring to ensure your new unit tests actually catch defects.

FAQ

Q: Should I refactor everything at once? No. Pick the highest-pain seam (usually the one that makes tests slowest or most brittle) and refactor that first. Ship. Measure. Repeat.

Q: Won't this create too many files? Yes, you'll have more files. Each file is smaller, focused, and independently testable. Navigation in an IDE is trivial. Understanding any single file is much faster.

Q: How do I convince my team this is worth the time? Measure test run time before and after. Show the reduction in cyclomatic complexity. Show that adding a new payment type takes one file instead of editing a tested class. Numbers are persuasive.

Related Tutorials

Where SOLID misleads if you apply it literally

Applied dogmatically, SOLID produces codebases as bad as the ones it was meant to prevent. Three real anti-patterns to watch for. SRP taken to an extreme produces micro-classes with three lines each, and the actual behaviour of the system is buried across dozens of files no one can hold in their head. Cohesion goes down, not up. OCP misread as 'never modify' leads to inheritance towers and premature strategy patterns for variations that never materialize; YAGNI beats OCP most of the time. DIP applied everywhere produces an interface for every class, doubling the file count and adding a mental hop between call site and implementation for zero test benefit — you needed the interface at the module boundary, not at every internal seam. The principles work best as diagnostic tools when reading existing code, not as generative rules when writing new code.

Go deeper

Further reading

#SOLID#OOP#Clean Code#Java#SRP#OCP#Spring Boot#Strategy#LSP#Inheritance#ISP#Interfaces#DIP#Dependency Injection#Refactoring

Stay in the Loop

Get the next tutorial in your inbox

Related tutorials