Writing software that scales from a small monolith into a multi-team distributed system requires strict architectural discipline. The SOLID principles—coined by Robert C. Martin ("Uncle Bob")—serve as fundamental guidelines for object-oriented design and system architecture.
When improperly understood, developers often fall into two extreme traps: creating monolithic "God objects" that break with every change, or over-engineering systems into hyper-fragmented, unmaintainable micro-services.
In this deep-dive guide, we will break down each of the 5 SOLID principles from low-level class design up to high-level distributed systems design, complete with bad vs. refactored Java examples, system architecture diagrams, trade-off analyses, and a comprehensive cheat sheet.
SOLID Principles Cheat Sheet
| Principle | Core Concept | Anti-Pattern / Code Smell | Refactoring Solution |
|---|---|---|---|
| Single Responsibility (SRP) | A class or module should have one, and only one, reason to change (serving one business actor/domain). | God Class / Micro-Fragmentation: Classes handling payment, DB, and notifications, OR over-fragmented single-function classes. | Split by domain responsibility. Use orchestrator/coordinator components for workflows. |
| Open/Closed (OCP) | Software entities should be open for extension, but closed for modification. |
Conditional Bloat: Cascading if-else or switch statements checking object types or channels. |
Strategy Pattern, Dependency Injection, and Event-Driven Pub/Sub messaging (e.g., Kafka). |
| Liskov Substitution (LSP) | Subtypes must be completely substitutable for their base types without breaking client behavior. |
Runtime Exceptions: Subclasses throwing UnsupportedOperationException or silently breaking logic. |
Split fat inheritance hierarchies into granular, capability-specific interfaces. |
| Interface Segregation (ISP) | No client should be forced to depend on methods it does not use. | Fat Interfaces: Monolithic interfaces forcing callers to mock or implement irrelevant methods. | Role-focused, lean interfaces broken down by client requirements. |
| Dependency Inversion (DIP) | High-level business logic must depend on abstractions, not low-level infrastructure details. |
Direct Instantiation: Hardcoding new ConcreteRepository() or new ThirdPartySdk() inside core logic. |
Pass interface dependencies via Constructor Injection (Inversion of Control). |
1. Single Responsibility Principle (SRP)
"A class or module should have one, and only one, reason to change."
SRP is often misunderstood as "a class should only have one function". That is incorrect. The core idea is that a module or class should solve one business need and serve one business actor (e.g., Finance, Fulfillment, or Marketing).
+-------------------------------------------------------------+
| OrderProcessor |
|-------------------------------------------------------------|
| 1. Business Logic & Validation (Domain) |
| 2. Payment API Calls (Finance Actor) |
| 3. Database SQL Operations (Fulfillment Actor) |
| 4. SMTP Email Delivery (Marketing Actor) |
+-------------------------------------------------------------+
|
v VIOLATES SRP!
(Any change across 4 different domains forces editing this file)
Enter fullscreen mode Exit fullscreen mode
Consequences of Violating SRP
- Hard to Debug: Cross-domain logic leads to unexpected side effects during runtime.
- Modification Bottlenecks: Simple feature changes require navigating massive, fragile files.
- Merge Conflicts: Multiple engineers working on different domain requirements touch the exact same file, slowing down deployments.
SRP Across Architectural Boundaries
SRP applies at three distinct levels:
- Class Level: Defining clear boundaries and single responsibility per class.
- Module Level: Grouping cohesive classes into tightly bounded packages.
- Microservice Level: Defining domain boundaries around single business domains.
SRP Anti-Patterns
Class-Level Anti-Pattern: Hyper-Fragmentation
-
The Mistake: Developers create separate classes like
EmailValidator,AgeValidator,PhoneNumberValidator, etc. - Why it Hurts: It confuses "one responsibility" with "one function." It destroys readability, inflates the class count, and increases CPU in-cache misses during execution.
-
Correct Approach: A single
UserValidatorclass that validates all parameters related to theUserentity is sufficient, as all these validation rules belong to the exact same business domain.
Microservice-Level Anti-Pattern: Over-Servicicing
-
The Mistake: Creating separate microservices for
EmailNotificationService,SMSNotificationService, andPushNotificationService. - Why it Hurts: Adds unnecessary network hop latency. In scenarios like Amazon order deliveries, where all three notifications may need to be triggered together, this creates distributed transaction overhead.
-
Correct Approach: A unified
NotificationServicemicroservice is optimal unless separate scaling requirements or distinct async guarantees demand isolation.
Code Example: SRP Refactoring
❌ BAD DESIGN: Monolithic OrderProcessor
public class OrderProcessor {
public void processOrder(Order order) {
// 1. Business Logic & Validation
if (order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order cannot be empty");
}
// 2. Payment Processing (Finance Actor)
System.out.println("Connecting to payment API...");
boolean paymentSuccess = paymentService.makePayment(); // Simulated HTTP Call
if (!paymentSuccess) {
throw new RuntimeException("Payment failed");
}
order.setStatus("PAID");
// 3. Database Operations (Fulfillment Actor)
order.save();
// 4. Notification Engine (Marketing Actor)
String emailBody = "Thank you for your order #" + order.getId();
System.out.println("Sending SMTP email to " + order.getCustomerEmail() + ": " + emailBody);
}
}
Enter fullscreen mode Exit fullscreen mode
✅ REFACTORED CODE: Domain-Separated Architecture
// --- Component 1: Payment Domain (Finance) ---
public interface PaymentGateway {
boolean charge(String orderId, double amount);
}
public class StripePaymentGateway implements PaymentGateway {
@Override
public boolean charge(String orderId, double amount) {
// Handles Stripe API calls exclusively
return true;
}
}
// --- Component 2: Persistence Domain (Fulfillment) ---
public class OrderRepository {
public void save(Order order) {
// Handles Database SQL operations exclusively
}
}
// --- Component 3: Notification Domain (Marketing) ---
public class NotificationService {
public void sendOrderConfirmation(Order order) {
// Handles Email/SMS template rendering & delivery exclusively
}
}
// --- Coordinator: Orchestrates flow without executing underlying implementation ---
public class OrderProcessor {
private final PaymentGateway paymentGateway;
private final OrderRepository orderRepository;
private final NotificationService notificationService;
public OrderProcessor(PaymentGateway paymentGateway,
OrderRepository orderRepository,
NotificationService notificationService) {
this.paymentGateway = paymentGateway;
this.orderRepository = orderRepository;
this.notificationService = notificationService;
}
public void processOrder(Order order) {
if (order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order cannot be empty");
}
if (!paymentGateway.charge(order.getId(), order.getTotalAmount())) {
throw new RuntimeException("Payment failed");
}
order.setStatus("PAID");
orderRepository.save(order);
notificationService.sendOrderConfirmation(order);
}
}
Enter fullscreen mode Exit fullscreen mode
2. Open/Closed Principle (OCP)
"Software entities (classes, modules, functions) should be open for extension, but closed for modification."
Adding a new feature should mean writing new code, not editing existing tested code.
Real-World Scenario
Imagine a payment engine that supports Credit Card and PayPal. The business expands to support Bank Transfers via Stripe. Stripe isn't supported in all regions. A developer modifies the core PaymentProcessor file with a massive if-else statement to handle Stripe validations.
During this modification, an edge case in Credit Card validation is accidentally modified and broken in production. OCP protects systems from this regression risk.
Code Example: OCP Refactoring
❌ BAD DESIGN: Rigid Conditional Checks
public class PaymentProcessor {
public void processPayment(String paymentType, double amount) {
if (paymentType.equalsIgnoreCase("CREDIT_CARD")) {
if (supportsPaymentMethod("CREDIT_CARD")) {
System.out.println("Processing Credit Card via Stripe: $" + amount);
// Stripe API logic
}
} else if (paymentType.equalsIgnoreCase("PAYPAL")) {
if (supportsPaymentMethod("PAYPAL")) {
System.out.println("Processing PayPal payment: $" + amount);
// PayPal API logic
}
} else if (paymentType.equalsIgnoreCase("BANK_TRANSFER")) {
if (supportsPaymentMethod("BANK_TRANSFER")) {
System.out.println("Processing Direct Wire Transfer: $" + amount);
// ACH/Wire transfer logic
}
} else {
throw new IllegalArgumentException("Unsupported payment type: " + paymentType);
}
}
private boolean supportsPaymentMethod(String paymentType) {
return true;
}
}
Enter fullscreen mode Exit fullscreen mode
✅ REFACTORED CODE: Open for Extension via Strategy Pattern
// --- Stable Contract (Closed for modification) ---
public interface PaymentStrategy {
boolean supports(String paymentType);
void process(double amount);
}
// --- Concrete Implementations (Open for extension) ---
public class CreditCardPayment implements PaymentStrategy {
@Override
public boolean supports(String paymentType) {
return "CREDIT_CARD".equalsIgnoreCase(paymentType);
}
@Override
public void process(double amount) {
System.out.println("Processing Credit Card via Stripe: $" + amount);
}
}
public class PaypalPayment implements PaymentStrategy {
@Override
public boolean supports(String paymentType) {
return "PAYPAL".equalsIgnoreCase(paymentType);
}
@Override
public void process(double amount) {
System.out.println("Processing PayPal payment: $" + amount);
}
}
// --- NEW FEATURE ADDED WITHOUT TOUCHING EXISTING CODE ---
public class ApplePayPayment implements PaymentStrategy {
@Override
public boolean supports(String paymentType) {
return "APPLE_PAY".equalsIgnoreCase(paymentType);
}
@Override
public void process(double amount) {
System.out.println("Processing Apple Pay: $" + amount);
}
}
// --- Core Engine (Never needs to change again) ---
public class PaymentProcessor {
private final List<PaymentStrategy> strategies;
// Dependency injection automatically registers all strategy implementations
public PaymentProcessor(List<PaymentStrategy> strategies) {
this.strategies = strategies;
}
public void processPayment(String paymentType, double amount) {
PaymentStrategy strategy = strategies.stream()
.filter(s -> s.supports(paymentType))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unsupported payment: " + paymentType));
strategy.process(amount);
}
}
Enter fullscreen mode Exit fullscreen mode
Architectural Trade-Offs of OCP
-
Gained: Zero-risk extensions & parallel development. Team A can build
ApplePayPaymentwhile Team B buildsCryptoPayment. Neither team touches core orchestrator files or creates merge conflicts. - Cost Introduced: Indirection & discovery cost. Tracing runtime execution requires navigating through dynamic dependency injection configurations rather than reading direct code flows.
Scaling OCP to Microservices & Distributed Systems
When scaling platforms like Shopify or Amazon, multiple teams must react when an order is completed:
- Fulfillment Team: Generates a shipping label.
- Marketing Team: Sends a discount coupon email.
- Analytics Team: Updates daily revenue charts.
- Fraud Team: Checks transaction risk markers.
Rigid Architecture (Direct Synchronous Calls)
[ Order Service ] ---> (Calls Fulfillment API)
---> (Calls Marketing API)
---> (Calls Analytics API)
---> (Calls Fraud API) <-- NEW REQUIREMENT REQUIRES DEPLOYING ORDER SERVICE
Enter fullscreen mode Exit fullscreen mode
- Failure Mode: Order Service becomes the team bottleneck. If Analytics goes down or experiences latency, checkout fails for end users.
OCP Microservice Architecture (Pub/Sub Event Bus)
graph TD
subgraph Core ["Core System (Closed for Modification)"]
OS[Order Service - Producer]
end
subgraph Broker ["Event Bus"]
KT[("Kafka Topic: order-events")]
end
subgraph Extension ["Consumers (Open for Extension)"]
FS[Fulfillment Service]
MS[Marketing Service]
AS[Analytics Service]
FrS[Fraud Service - Extension]
end
OS -->|1. Publish OrderPlaced Event| KT
KT -->|2. Consume Event| FS
KT -->|2. Consume Event| MS
KT -->|2. Consume Event| AS
KT -->|2. Consume Event| FrS
Enter fullscreen mode Exit fullscreen mode
-
Closed for Modification: The
Order Servicecodebase is 100% frozen. It doesn't care who listens toOrderPlacedevents. -
Open for Extension: When the Fraud team launches 6 months later, they deploy the standalone
Fraud Servicemicroservice and subscribe to Kafka. Zero PRs or deployments are required from the Order team.
Architectural Choice: Synchronous Dispatcher vs. Distributed Pub/Sub
Is Kafka always necessary? Not always.
| Metric / Consideration | Approach A: In-Process NotificationDispatcher
|
Approach B: Distributed Event-Driven (Kafka) |
|---|---|---|
| Latency | Extremely low (In-memory calls). | Higher (Network hops, broker queuing, consumer polling). |
| Operational Complexity | Simple (Zero external infrastructure required). | High (Requires Kafka cluster, DLQs, tracing tools). |
| System Resiliency | Lower (Failure in 1 sync handler impacts caller). | High (Isolated consumer failures, built-in queue buffering). |
| Throughput Scaling | Limited by single node CPU/RAM limits. | Massive horizontal scale across independent consumer worker groups. |
Decision Framework: When to Use Which?
Choose Approach A (In-Process Dispatcher) IF:
-
Low Volume / Early Stage: Application handles
<10,000events/day in a monolithic setup. - Synchronous Feedback Needed: Callers must confirm processing success inside the HTTP request (e.g., validating 2FA OTP codes).
- Low Operational Overhead: No dedicated DevOps infrastructure team available.
Choose Approach B (Distributed Pub/Sub) IF:
- High Scale & Burst Traffic: System handles millions of daily operations or experiences extreme flash sales.
- Third-Party Rate Limits: Dependent on external vendor APIs (Twilio, SendGrid, WhatsApp) that rate-limit or experience downtime. The message broker acts as a shock absorber.
- Cross-Team Independence (Conway's Law): Independent business teams need to process data asynchronously without blocking checkout flows.
The Production Reality: Hybrid Pattern
graph TD
API[Core Order / Auth API]
API -->|Critical 2FA OTP| Dispatcher[In-Process Dispatcher]
Dispatcher -->|Fast Sync Call| User[Immediate Response]
API -->|Non-Critical Notifications| Bus[Kafka / Event Bus]
subgraph AsyncWorkers ["Async Consumer Workers"]
Bus --> EW[Email Worker - Strategy]
Bus --> PW[Push Notif Worker - Strategy]
end
Enter fullscreen mode Exit fullscreen mode
3. Liskov Substitution Principle (LSP)
"Subtypes must be substitutable for their base types without altering the correctness of the program."
LSP ensures that polymorphism is safe at scale. If a subclass throws unexpected runtime exceptions, ignores methods, or alters behavior unexpectedly, client code breaks in production.
Real-World Example: Non-Refundable Payment Methods
Consider a payment engine handling refunds. A developer adds GiftCardPaymentService extending PaymentService. However, gift cards cannot be refunded back to cash; they only support store credit.
If the subclass throws UnsupportedOperationException when calling .refund(), it violates LSP and breaks downstream refund processing pipelines.
PaymentService (refunds allowed)
▲
│
┌────────────────┴────────────────┐
│ │
CreditCardPayment GiftCardPayment
(Refunds work) (Throws UnsupportedOperationException!)
▲
│
CRASHES RefundProcessor!
Enter fullscreen mode Exit fullscreen mode
Code Example: LSP Refactoring
❌ BEFORE (Violates LSP)
import java.math.BigDecimal;
public class PaymentService {
public void processPayment(String orderId, BigDecimal amount) {
System.out.println("Processing payment of $" + amount + " for order " + orderId);
}
public void refund(String transactionId, BigDecimal amount) {
System.out.println("Refunding $" + amount + " for transaction " + transactionId);
}
}
public class GiftCardPaymentService extends PaymentService {
@Override
public void processPayment(String orderId, BigDecimal amount) {
System.out.println("Deducting $" + amount + " from gift card balance for order " + orderId);
}
@Override
public void refund(String transactionId, BigDecimal amount) {
// VIOLATION: Throws runtime exception for an inherited parent contract method!
throw new UnsupportedOperationException("Gift card transactions cannot be refunded to cash.");
}
}
public class RefundProcessor {
public void handleCustomerRefund(PaymentService paymentService, String transactionId, BigDecimal amount) {
// Crashes with runtime exception if passed an instance of GiftCardPaymentService!
paymentService.refund(transactionId, amount);
}
}
Enter fullscreen mode Exit fullscreen mode
✅ AFTER (LSP Compliant via Interface Segregation)
import java.math.BigDecimal;
// Core contract: Every payment method can charge
public interface ChargeablePaymentService {
void processPayment(String orderId, BigDecimal amount);
}
// Extended contract: Only refundable payment methods implement this
public interface RefundablePaymentService extends ChargeablePaymentService {
void refund(String transactionId, BigDecimal amount);
}
// Standard Credit Card supports processing AND refunds
public class CreditCardPaymentService implements RefundablePaymentService {
@Override
public void processPayment(String orderId, BigDecimal amount) {
System.out.println("Charging credit card $" + amount + " for order " + orderId);
}
@Override
public void refund(String transactionId, BigDecimal amount) {
System.out.println("Refunding $" + amount + " to credit card for transaction " + transactionId);
}
}
// Gift Card ONLY implements ChargeablePaymentService
public class GiftCardPaymentService implements ChargeablePaymentService {
@Override
public void processPayment(String orderId, BigDecimal amount) {
System.out.println("Deducting $" + amount + " from gift card for order " + orderId);
}
// Invalid refund method does not exist. Invalid calls caught AT COMPILE TIME!
}
public class RefundProcessor {
// Explicitly requires RefundablePaymentService contract
public void handleCustomerRefund(RefundablePaymentService paymentService, String transactionId, BigDecimal amount) {
paymentService.refund(transactionId, amount); // 100% type-safe
}
}
Enter fullscreen mode Exit fullscreen mode
LSP at the Microservice Boundary
In distributed systems, LSP means API contract changes must remain backward-compatible. API producers should not remove fields or change response behavior in ways that force consumers to upgrade immediately or handle breaking exception payloads.
4. Interface Segregation Principle (ISP)
"No client should be forced to depend on methods it does not use."
Large, monolithic interfaces ("fat interfaces") force implementations to write empty methods or throw UnsupportedOperationException. They should be split into lean, client-focused role interfaces.
Benefits of ISP
- No Unused Code: Consumers only implement methods they care about.
- Isolated Testing: Eliminates the need to mock dozens of unused methods in unit tests.
- Safer Refactoring: Updating one domain method does not invalidate unrelated implementations.
Code Example: ISP Refactoring
❌ BEFORE (Violates ISP)
// Fat Interface: Forces all implementations to handle every concern
public interface OrderProcessor {
void processOrder(String orderId);
void cancelOrder(String orderId);
void auditOrderHistory(String orderId);
void exportMetricsToDataLake();
}
public class MobileOrderService implements OrderProcessor {
@Override
public void processOrder(String orderId) {
System.out.println("Processing mobile order: " + orderId);
}
@Override
public void cancelOrder(String orderId) {
System.out.println("Canceling mobile order: " + orderId);
}
// Irrelevant to mobile checkout!
@Override
public void auditOrderHistory(String orderId) {
throw new UnsupportedOperationException("Mobile backend does not perform audits.");
}
// Irrelevant to mobile checkout!
@Override
public void exportMetricsToDataLake() {
throw new UnsupportedOperationException("Mobile backend does not export data lake metrics.");
}
}
Enter fullscreen mode Exit fullscreen mode
✅ AFTER (ISP Compliant)
// Granular, role-focused interfaces
public interface OrderLifecycleManager {
void processOrder(String orderId);
void cancelOrder(String orderId);
}
public interface OrderAuditor {
void auditOrderHistory(String orderId);
}
public interface OrderAnalyticsExporter {
void exportMetricsToDataLake();
}
// Mobile Service implements only what it executes
public class MobileOrderService implements OrderLifecycleManager {
@Override
public void processOrder(String orderId) {
System.out.println("Processing mobile order: " + orderId);
}
@Override
public void cancelOrder(String orderId) {
System.out.println("Canceling mobile order: " + orderId);
}
}
// Admin Service implements multiple interfaces as needed
public class EnterpriseAdminService implements OrderLifecycleManager, OrderAuditor {
@Override
public void processOrder(String orderId) { /* ... */ }
@Override
public void cancelOrder(String orderId) { /* ... */ }
@Override
public void auditOrderHistory(String orderId) {
System.out.println("Auditing order: " + orderId);
}
}
Enter fullscreen mode Exit fullscreen mode
5. Dependency Inversion Principle (DIP)
"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."
DIP removes hardcoded infrastructure dependencies (PostgresDatabase, TwilioSmsClient, AWS S3) from core application logic.
Benefits of DIP
- Pluggable Infrastructure: Swap cloud providers or database vendors without touching core business logic.
- Isolated Unit Testing: Test domain logic quickly using mock abstractions without spinning up databases or network listeners.
Code Example: DIP Refactoring
❌ BEFORE (Violates DIP)
// Low-level infrastructure classes
public class TwilioSmsClient {
public void sendSms(String phone, String msg) {
System.out.println("Sending Twilio SMS to " + phone);
}
}
public class PostgresDatabase {
public void insertOrder(String orderId) {
System.out.println("Saving order " + orderId + " to Postgres");
}
}
// High-level business logic
public class CheckoutService {
// VIOLATION: Directly instantiating concrete infrastructure implementations!
private TwilioSmsClient smsClient = new TwilioSmsClient();
private PostgresDatabase database = new PostgresDatabase();
public void checkout(String orderId, String userPhone) {
database.insertOrder(orderId);
smsClient.sendSms(userPhone, "Order " + orderId + " confirmed!");
}
}
Enter fullscreen mode Exit fullscreen mode
✅ AFTER (DIP Compliant)
// Domain Abstractions (Interfaces)
public interface NotificationPublisher {
void notifyUser(String recipient, String message);
}
public interface OrderRepository {
void saveOrder(String orderId);
}
// Low-Level Implementations adapting to abstractions
public class TwilioNotificationPublisher implements NotificationPublisher {
@Override
public void notifyUser(String recipient, String message) {
System.out.println("Sending Twilio SMS to " + recipient);
}
}
public class PostgresOrderRepository implements OrderRepository {
@Override
public void saveOrder(String orderId) {
System.out.println("Saving order " + orderId + " to Postgres");
}
}
// High-Level Business Logic depends ONLY on abstractions
public class CheckoutService {
private final NotificationPublisher notificationPublisher;
private final OrderRepository orderRepository;
// Dependency Injection via Constructor
public CheckoutService(NotificationPublisher notificationPublisher,
OrderRepository orderRepository) {
this.notificationPublisher = notificationPublisher;
this.orderRepository = orderRepository;
}
public void checkout(String orderId, String userPhone) {
// Pure domain logic—100% decoupled from database & vendor details!
orderRepository.saveOrder(orderId);
notificationPublisher.notifyUser(userPhone, "Order " + orderId + " confirmed!");
}
}
Enter fullscreen mode Exit fullscreen mode
Conclusion & Architecture Roadmap
The SOLID principles are not academic rules to be applied blindly; they are tools to manage system complexity.
┌──────────────────────────────────────────────┐
│ SOLID Architecture │
└──────────────────────┬───────────────────────┘
│
┌────────────────────────────┼────────────────────────────┐
▼ ▼ ▼
Class Level Module Boundary Distributed Scale
• Lean interfaces (ISP) • Single Actor boundaries • Pub/Sub Event Bus (OCP)
• Type safety (LSP) (SRP) • Isolated microservices
• Inject abstractions • Domain-driven packaging • Stable API contracts
(DIP) structure (LSP)
Enter fullscreen mode Exit fullscreen mode
- Start with SRP and DIP: Establish clean domain boundaries and inject dependencies.
- Apply OCP and LSP as complexity grows: Use Strategy patterns and proper interface hierarchies to make adding features simple and safe.
- Scale OCP to Distributed Systems: Use Event-Driven Pub/Sub messaging (like Kafka) to keep microservices decoupled and resilient.
💡 Get the Full 10-Page System Design Guide
Subscribe to The Tech Builder Newsletter to instantly get the full, unredacted guide for free.
Every week, subscribers receive:
- 🎯 Deep-dive production postmortems & system design trade-off analysis.
- 🛠️ Real-world architecture playbooks for Senior ICs, Tech Leads, and Architects.
- 🎁 Instant Bonus: Get the Full 6-Month Prep Tracker & Study Schedule + 10-Page System Design Cheat Sheet immediately upon subscribing.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.