SOLID Principles
SOLID is not a checklist for code reviews — it is the engineering vocabulary staff architects use to explain why one design scales to 200 microservices and another collapses at 20.
Introduction
SOLID is not a checklist for code reviews — it is the engineering vocabulary staff architects use to explain why one design scales to 200 microservices and another collapses at 20. Coined by Robert C. Martin ("Uncle Bob"), SOLID transforms five OOP principles into actionable rules for enterprise Java:
S — Single Responsibility: one class, one reason to change. O — Open/Closed: open for extension, closed for modification. L — Liskov Substitution: subtypes must be substitutable without surprises. I — Interface Segregation: clients depend only on methods they use. D — Dependency Inversion: depend on abstractions, not concretions.
Spring Boot is built on SOLID — constructor injection (D), interface-based repositories (I), @Profile strategy beans (O), and focused @Service classes (S). Violating SOLID in a payment platform means every new payment rail requires editing core transfer logic, every gateway swap breaks half the codebase, and every PR touches the same 2,000-line god class. This lesson teaches SOLID through banking refactorings you can apply immediately in Spring Boot services.
Business problem
Teams that ignore SOLID pay in velocity, incidents, and hiring:
- God classes:
PaymentServicevalidates, processes, sends email, generates PDF, logs audit — one change to email template risks breaking payment processing. - Modification cascades: Adding cryptocurrency payments requires editing
TransferService,NotificationService, andReportService— 3 teams, 3 PRs, 3 merge conflicts. - Fragile tests: Unit test for overdraft logic requires mocking database, SMTP server, and PDF library — because one class does everything (SRP violation).
- Concrete coupling:
new StripeGateway()hardcoded in service — switching to Adyen requires rewriting business logic, not swapping a bean.
Why this topic exists
SOLID exists because unstructured OOP degrades into worse spaghetti than procedural code:
- Change isolation: SRP ensures a bug in email notification cannot corrupt payment calculation — different classes, different deploy risk.
- Safe extension: OCP lets you add FedNow rail without touching existing ACH code — new class, zero modification of tested paths.
- Correct polymorphism: LSP guarantees SavingsAccount can replace Account anywhere without breaking transfer invariants.
- Minimal coupling: ISP prevents FraudService from depending on 15 unused AccountRepository methods.
- Testability and swap: DIP enables Spring to inject mock PaymentProcessor in tests and real SWIFT client in production.
Core concepts
SOLID — definitions with banking context:
- S — Single Responsibility Principle (SRP): A class should have only one reason to change.
PaymentValidatorvalidates;PaymentProcessorprocesses;PaymentNotifiersends alerts. Not one class doing all three. - O — Open/Closed Principle (OCP): Open for extension (new payment types), closed for modification (existing transfer flow unchanged). Add
CryptoPaymentProcessor implements PaymentProcessor— no edits toTransferService. - L — Liskov Substitution Principle (LSP): Subtypes must honor the parent contract.
Account acc = new SavingsAccount(); acc.withdraw(money)must not throw unexpected exceptions or return wrong types. - I — Interface Segregation Principle (ISP): No client should depend on methods it doesn't use. Split
AccountOperationsintoReadableAccount+WritableAccount— read-only reporting service depends only on read interface. - D — Dependency Inversion Principle (DIP): High-level modules (TransferService) depend on abstractions (PaymentProcessor interface), not low-level modules (StripeGateway concrete class). Spring @Autowired/@Inject enforces this.
Internal architecture
SOLID-compliant payment microservice architecture:
┌──────────────────── High Level (depends on abstractions) ────────────────────┐│ TransferService ──depends on──▶ PaymentProcessor (interface) [D - DIP] ││ ──depends on──▶ AccountRepository (interface) ││ ──depends on──▶ PaymentNotifier (interface) │└───────────────────────────────┬──────────────────────────────────────────────┘│ implements / injected by Spring┌───────────────────────┼───────────────────────┐▼ ▼ ▼ACHProcessor WireTransferProcessor CryptoProcessor [O - OCP](new rail = new class) (existing unchanged) (extension only)│ │ │└───────────────────────┼───────────────────────┘│┌───────────────────────────────▼──────────────────────────────────────────────┐│ PaymentValidator [S] │ AuditLogger [S] │ EmailNotifier [S] ││ (one reason to change each) │└──────────────────────────────────────────────────────────────────────────────┘ReadableAccount (interface) [I] WritableAccount (interface) [I]│ │└──────── AccountRepository ─────────┘│CheckingAccount / SavingsAccount [L - substitutable subtypes]
SOLID principle map — five diagrams showing each rule in a payment system:
Code walkthrough
Before/after refactoring — all five SOLID principles applied to a payment service:
- SRP refactor: Extracted PaymentValidator, EmailNotifier, ConsoleAuditWriter — each changes for one reason only.
- OCP refactor: New CryptoPaymentProcessor added — BadPaymentService would need if/else edit; TransferService untouched.
- DIP refactor: TransferService depends on PaymentProcessor interface — Spring injects ACH or Crypto bean at runtime.
- ISP refactor: Separate PaymentNotifier and AuditWriter — not one fat Operations interface with 20 methods.
// ═══════════════════════════════════════════════════════════════// BEFORE — violates SRP, OCP, DIP (common enterprise anti-pattern)// ═══════════════════════════════════════════════════════════════class BadPaymentService {public void processPayment(String from, String to, BigDecimal amount) {// SRP violation: validation + processing + notification + audit in one methodif (amount.compareTo(BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("bad amount");StripeGateway stripe = new StripeGateway(); // DIP violation: concrete dependencystripe.charge(from, amount);sendEmail(from, "Payment sent"); // SRP: notification mixed inwriteAuditLog(from, to, amount); // SRP: audit mixed in// OCP violation: adding crypto means editing this method with if/elseif ("CRYPTO".equals(getPaymentType())) { /* new branch */ }}}// ═══════════════════════════════════════════════════════════════// AFTER — SOLID-compliant Spring Boot design// ═══════════════════════════════════════════════════════════════// [I] Interface Segregation — focused contractsinterface PaymentProcessor { PaymentResult process(Payment payment); }interface PaymentNotifier { void notify(PaymentEvent event); }interface AuditWriter { void write(AuditEntry entry); }// [S] Single Responsibility — one class per concernclass PaymentValidator {public void validate(Payment payment) {if (payment.amount().compareTo(BigDecimal.ZERO) <= 0)throw new IllegalArgumentException("Amount must be positive");}}// [O + D] Open for extension via new implementations; depend on interfaceclass ACHPaymentProcessor implements PaymentProcessor {public PaymentResult process(Payment payment) {System.out.println("ACH: processing " + payment.amount());return PaymentResult.success();}}class CryptoPaymentProcessor implements PaymentProcessor {public PaymentResult process(Payment payment) {System.out.println("Crypto: processing " + payment.amount());return PaymentResult.success();}}// [D] High-level service depends on abstractions — Spring constructor injectionclass TransferService {private final PaymentValidator validator;private final PaymentProcessor processor; // interface — not StripeGatewayprivate final PaymentNotifier notifier;private final AuditWriter auditWriter;TransferService(PaymentValidator v, PaymentProcessor p,PaymentNotifier n, AuditWriter a) {this.validator = v; this.processor = p;this.notifier = n; this.auditWriter = a;}public PaymentResult transfer(Payment payment) {validator.validate(payment); // S: delegatedPaymentResult result = processor.process(payment); // D + O: polymorphicauditWriter.write(new AuditEntry(payment)); // S: delegatednotifier.notify(new PaymentEvent(payment)); // S: delegatedreturn result;}}// Demo — swap processor without changing TransferService [OCP + DIP]record Payment(String from, String to, BigDecimal amount) {}record PaymentResult(boolean ok) { static PaymentResult success() { return new PaymentResult(true); } }record PaymentEvent(Payment p) {}record AuditEntry(Payment p) {}class EmailNotifier implements PaymentNotifier {public void notify(PaymentEvent e) { System.out.println("Email sent to " + e.p().from()); }}class ConsoleAuditWriter implements AuditWriter {public void write(AuditEntry e) { System.out.println("Audit: " + e.p().from() + " → " + e.p().to()); }}class SOLIDDemo {public static void main(String[] args) {Payment p = new Payment("ACC-1", "ACC-2", new BigDecimal("250.00"));// Wire with ACH processorTransferService ach = new TransferService(new PaymentValidator(), new ACHPaymentProcessor(),new EmailNotifier(), new ConsoleAuditWriter());ach.transfer(p);// Swap to Crypto — TransferService unchanged [OCP]TransferService crypto = new TransferService(new PaymentValidator(), new CryptoPaymentProcessor(),new EmailNotifier(), new ConsoleAuditWriter());crypto.transfer(p);}}/** Expected output:* ACH: processing 250.00* Audit: ACC-1 → ACC-2* Email sent to ACC-1* Crypto: processing 250.00* Audit: ACC-1 → ACC-2* Email sent to ACC-1*/
Production example
Production Spring Boot — SOLID in action:
- Constructor injection (DIP): TransferService never imports Stripe or ACH classes — only PaymentProcessor interface.
- @Profile (OCP): Enable wire or ACH via application.yml — new processor = new @Service class.
- @EventListener (SRP): Audit and email are separate listeners — adding SMS notification = new listener, zero TransferService changes.
- @Transactional boundary: TransferService owns transaction; side effects async via events — clear responsibility.
// [D + I] Depend on focused interfaces@Servicepublic class TransferService {private final PaymentValidator validator;private final PaymentProcessor processor;private final ApplicationEventPublisher events;public TransferService(PaymentValidator validator,PaymentProcessor processor,ApplicationEventPublisher events) {this.validator = validator;this.processor = processor;this.events = events;}@Transactionalpublic PaymentResult transfer(PaymentCommand cmd) {validator.validate(cmd);PaymentResult result = processor.process(cmd.toPayment());events.publishEvent(new PaymentCompletedEvent(cmd, result)); // S: async side effectsreturn result;}}// [O + D] Extension via @Profile — add rail without editing TransferService@Service@Profile("ach")public class ACHPaymentProcessor implements PaymentProcessor { ... }@Service@Profile("wire")public class WirePaymentProcessor implements PaymentProcessor { ... }// [S] Single responsibility listeners@Componentpublic class PaymentAuditListener {@EventListenerpublic void onPaymentCompleted(PaymentCompletedEvent event) {auditRepository.save(event.toAuditEntry());}}@Componentpublic class PaymentEmailListener {@EventListenerpublic void onPaymentCompleted(PaymentCompletedEvent event) {emailService.sendReceipt(event.customerEmail());}}
Enterprise case study
Amazon — SOLID at service scale: Amazon's internal Java service guidelines enforce DIP via constructor injection and ban field @Autowired on concrete classes. When Amazon Payments added Buy Now Pay Later (BNPL), the team implemented BNPLPaymentProcessor implements PaymentProcessor — zero modifications to checkout flow that had processed billions of card transactions. Audit, email, and fraud checks ran via existing event listeners (SRP). Contrast: a team that violated OCP with switch-on-payment-type in CheckoutService required 6 weeks of regression testing for the same feature.
- Violation: CheckoutService with 800 lines and switch(paymentType) — SRP + OCP broken.
- Refactor: Extract PaymentProcessor strategy; event-driven side effects; interface-based repos.
- Result: BNPL added in 2 weeks with isolated test suite; checkout regression tests unchanged.
- Metric: PRs touching CheckoutService dropped from 40/month to 3/month after SOLID refactor.
Performance considerations
SOLID and performance — myths vs reality:
- Interface dispatch cost: Virtual method call ~1-2ns — negligible vs database/API I/O in payment services. JIT devirtualizes monomorphic calls.
- SRP vs call overhead: More classes = more method calls — nanoseconds vs milliseconds for network; always favor clarity and change isolation.
- Event-driven SRP: @EventListener async side effects decouple latency — transfer response not blocked by email/PDF generation.
- Over-SOLIDification: Interface with one impl and no expected variation — premature abstraction adds indirection without benefit.
Security considerations
SOLID supports security architecture:
- SRP for security: Authorization logic in dedicated
PaymentAuthorizer— not scattered across controllers and services. - ISP limits blast radius: Read-only reporting service implements ReadableAccount only — cannot invoke withdraw even if compromised.
- DIP enables security testing: Inject mock FraudChecker in tests; swap production impl without changing TransferService.
- LSP prevents security bypass: RestrictedAccount subtype must not weaken withdraw validation when substituted for Account.
Scalability considerations
SOLID enables team and system scale:
- Parallel team ownership: SRP boundaries map to team boundaries — Payments team owns Processor, Notifications team owns Notifier.
- Independent deploy: OCP-compliant extensions deploy as new modules — BNPL processor canary without checkout redeploy.
- Test pyramid: DIP makes unit tests fast — mock interfaces, no Spring context needed for TransferService logic.
- Feature flags: @ConditionalOnProperty on PaymentProcessor beans — OCP + Spring enables gradual rollout.
Production challenges
Common SOLID violations in enterprise Java codebases:
- God @Service: OrderService with 60 methods — create, update, cancel, refund, email, report, export, sync — classic SRP violation.
- Switch on type:
switch(paymentType) { case ACH: ... case WIRE: ... }— OCP violation; every new type edits core logic. - Field injection:
@Autowired StripeGateway gateway;— DIP violation; hard to test, hidden dependency. - Fat interface:
AccountServicewith 25 methods — ISP violation; reporting client forced to depend on write operations. - LSP break: ReadOnlyAccount extends Account but withdraw() throws UnsupportedOperationException — callers expecting Account contract break.
Common mistakes
- Applying SRP so aggressively that every line is a new class — balance cohesion with responsibility.
- Creating interfaces for everything "because SOLID says so" — YAGNI applies; abstract when variation exists.
- Confusing OCP with "never modify any code" — fix bugs in place; OCP means extend behavior without changing stable abstractions.
- Assuming inheritance satisfies LSP automatically — overriding with throws or silent behavior change breaks substitutability.
- Using @Autowired field injection in Spring — constructor injection is DIP-compliant and test-friendly.
Debugging guide
Identify SOLID violations during code review or incidents:
- SRP smell: Class imports from 5+ unrelated packages (email, PDF, SQL, HTTP, crypto) — split by reason to change.
- OCP smell: Git blame shows same file edited by every feature team — extract strategy/interface.
- LSP smell: instanceof checks before calling method on subtype — polymorphism broken.
- ISP smell: Client throws UnsupportedOperationException or no-op methods — interface too fat.
- DIP smell:
new ConcreteClass()inside @Service — inject interface via constructor.
# Find DIP violations — concrete instantiation in servicesgrep -rn "new.*Gateway\|new.*Repository\|new.*Client" src/ \--include="*Service.java"# Find OCP violations — switch on type/enums in business logicgrep -rn "switch.*[Tt]ype\|switch.*PaymentMethod" src/ --include="*.java"# ArchUnit test (enterprise standard)// noClasses().that().resideInAPackage("..service..")// .should().dependOnClassesThat().resideInAPackage("..gateway.stripe..")
Best practices
- One @Service class per use case or domain concern — PaymentValidator, TransferService, RefundService (SRP).
- Program to interfaces — inject PaymentProcessor, not ACHPaymentProcessor (DIP).
- Extend via new @Service implementations + @Profile/@Conditional — not switch statements (OCP).
- Split fat interfaces — ReadableAccount vs WritableAccount for read-only clients (ISP).
- Subtypes must not throw unexpected exceptions or weaken preconditions (LSP).
- Use constructor injection exclusively in Spring Boot 3.x services (DIP + testability).
- Enforce with ArchUnit in CI — automated SOLID regression tests on every PR.
Anti-patterns
- God Service: 2,000-line @Service — violates SRP; split by bounded context.
- Switch payment type: if/else or switch on enum in TransferService — violates OCP; use strategy pattern.
- Field @Autowired: Hidden dependency, cannot make field final — violates DIP.
- Empty interface impl: ReportService implements AccountService but 20 methods throw — violates ISP.
- Square extends Rectangle: Classic LSP violation — setWidth breaks square invariant.
Staff engineer notes
- Staff reviews ask "which SOLID principle does this violate?" — faster than debating taste.
- SOLID is not about more classes — it is about change isolation. If two things change for different reasons, split them.
- Spring Boot's entire design assumes DIP — fighting it with new ConcreteClass() in services fights the framework.
- ArchUnit tests encode SOLID as law: "services must not depend on gateway implementations" — enforce in CI, not code review memory.
- OCP is the most misunderstood — it does not forbid modification; it forbids modifying stable, tested core logic when extending behavior.
Interview questions
Interview preparation
15 questions grouped by difficulty — expand any card for a model answer and follow-up probe.
Beginner
1What does SOLID stand for?
BeginnerModel answer
- Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion
- five OOP design principles for maintainable, extensible code. Each addresses a specific coupling or rigidity problem.
Follow-up probe
Which is most important?
2Explain Single Responsibility Principle with an example.
BeginnerModel answer
- A class should have one reason to change. PaymentValidator only validates
- changes when validation rules change. PaymentProcessor only processes
- changes when gateway API changes. Bad: one class validates, processes, emails, and audits.
Follow-up probe
How small should a class be?
3Explain Open/Closed Principle.
BeginnerModel answer
- Software entities open for extension, closed for modification. Add CryptoPaymentProcessor implements PaymentProcessor
- new behavior without editing TransferService. Contrast: adding if/else branch to existing method violates OCP.
Follow-up probe
Does OCP mean never modify code?
4Explain Liskov Substitution Principle.
BeginnerModel answer
- Subtypes must be substitutable for base types without breaking behavior. Account acc = new SavingsAccount(); acc.withdraw(amount) must work as callers expect. Violation: ReadOnlyAccount.withdraw() throws
- callers of Account break.
Follow-up probe
Square extends Rectangle — LSP violation?
5Explain Dependency Inversion Principle.
BeginnerModel answer
High-level modules depend on abstractions, not low-level concretions.
TransferService depends on PaymentProcessor interface, not StripeGateway.
Spring injects concrete bean.
Enables testing with mocks and swapping implementations.
Follow-up probe
Field vs constructor injection?
Intermediate
6Explain Interface Segregation Principle.
IntermediateModel answer
- Clients should not depend on interfaces they don't use. Split fat AccountService (25 methods) into ReadableAccount + WritableAccount. ReportService depends only on ReadableAccount
- cannot accidentally call withdraw.
Follow-up probe
When is a fat interface OK?
7How does Spring Boot enforce SOLID?
IntermediateModel answer
DIP: constructor injection of interfaces.
OCP: @Profile/@Conditional beans for strategy variants.
SRP: separate @Service, @Component listeners.
ISP: Spring Data repository interfaces expose only needed CRUD.
LSP: your domain subtypes must honor contracts.
Follow-up probe
What breaks DIP in Spring?
8Refactor switch(paymentType) to satisfy OCP.
IntermediateModel answer
- Extract PaymentProcessor interface with process() method. Each type becomes a class: ACHProcessor, WireProcessor, CryptoProcessor. TransferService depends on PaymentProcessor
- Spring injects correct bean via @Profile or factory. New type = new class, zero TransferService edits.
Follow-up probe
Strategy vs factory pattern?
9How do you test DIP-compliant services?
IntermediateModel answer
Constructor-inject interfaces.
In unit test: new TransferService(mockValidator, mockProcessor, mockNotifier, mockAudit).
No Spring context.
thenReturn(success).
Fast, isolated, deterministic.
Follow-up probe
@SpringBootTest vs pure unit test?
10Give an LSP violation in banking code.
IntermediateModel answer
- PremiumAccount extends Account. Account.withdraw allows any amount. PremiumAccount.withdraw throws if balance would drop below $10,000 minimum
- callers expecting normal withdraw behavior break. Fix: separate PremiumWithdrawPolicy or compose instead of inherit.
Follow-up probe
How to detect LSP violations?
Advanced
11Design SOLID-compliant payment system for 5 payment rails.
AdvancedModel answer
PaymentProcessor interface (D). One @Service per rail: ACH, Wire, Card, Crypto, BNPL (OCP). TransferService injects Listor factory (D). PaymentValidator, AuditListener, EmailListener separate (SRP). ReadableAccount for reporting (ISP). Account subtypes substitutable (LSP). ArchUnit enforces package rules. Follow-up probe
How route to correct processor?
12Write ArchUnit rules for SOLID in a Spring Boot monorepo.
AdvancedModel answer
areInterfaces() or resideInPackage(domain).
impl).
haveOnlyPrivateConstructors or beSpringComponents.
accessClassesWithNameMatching('*Gateway') from service layer.
Follow-up probe
False positives?
13When does SOLID conflict with YAGNI?
AdvancedModel answer
- Creating PaymentProcessor interface with one implementation and no planned variation
- premature abstraction. Apply SOLID when second implementation appears or change frequency justifies boundary. SRP always applies; OCP/DIP/ISP intensify with scale and team count.
Follow-up probe
Rule of three?
14Refactor 2,000-line OrderService — approach?
AdvancedModel answer
- 1) Identify reasons to change (validation, persistence, notification, pricing, inventory). 2) Extract each as focused class (SRP). 3) Introduce interfaces for swappable deps (DIP). 4) Replace switch on order type with strategy (OCP). 5) Split fat repo interface (ISP). 6) Verify subtypes substitutable (LSP). 7) Strangler fig
- migrate one use case at a time with parallel tests.
Follow-up probe
How avoid big-bang rewrite?
15SOLID vs microservices — relationship?
AdvancedModel answer
- SOLID inside service; bounded context between services. SRP often maps to microservice boundary
- but microservice != single class SRP. DIP across services via API contracts (REST/events). OCP: new service for new capability vs modifying existing. Violating SOLID inside a microservice creates distributed monolith pain.
Follow-up probe
Shared library vs duplication?
Hands-on exercise
Lab: Refactor to SOLID — run the playground, then refactor:
- Run BadPaymentService demo — observe all concerns in one method.
- Extract PaymentValidator class (SRP) — move validation out of processPayment.
- Introduce PaymentProcessor interface and ACHPaymentProcessor (DIP + OCP).
- Refactor to TransferService with constructor injection — run again, same output.
- Add CryptoPaymentProcessor without editing TransferService — prove OCP.
- Bonus: write one ArchUnit-style rule as a comment describing what CI should enforce.
JavaSOLID Principles
Press Run to execute. Real javac + JVM via remote API. Falls back to simulator on network failure.Architecture trade-offs
- SRP vs cohesion: Split too early → fragmented logic; wait too long → god class. Split when reasons to change diverge.
- OCP vs YAGNI: OCP wins when extension frequency high; direct modification wins for stable, single-impl code.
- Interface vs concrete: Interface wins for testability and variation; concrete wins when no variation and team is tiny.
- Event-driven SRP vs synchronous: Events win for decoupling side effects; synchronous wins when strong consistency required.
Summary
SOLID transforms OOP from theory into enterprise engineering discipline. You can now identify violations in god services and switch statements, refactor toward Spring Boot-compliant design with constructor injection and strategy beans, and articulate each principle in architecture review. Next: design patterns that implement SOLID in practice.
Key takeaways
- S — one class, one reason to change: Validator, Processor, Notifier separate.
- O — extend via new PaymentProcessor impls; never edit stable TransferService.
- L — subtypes (SavingsAccount) must honor Account contract without surprises.
- I — ReadableAccount vs WritableAccount — clients depend only on what they use.
- D — depend on PaymentProcessor interface; Spring injects concrete bean.