A Solid Guide to SOLID Principles

Write code that survives the future — SOLID makes your software unbreakable.

The SOLID principles are five fundamental design principles that help create maintainable, scalable, and robust object-oriented software. These principles, introduced by Robert C. Martin (Uncle Bob), serve as guidelines for writing clean code that is easy to understand, modify, and extend. Let’s explore each principle with detailed examples and explanations.

Solid principles diagram by Sumit Kumar Singh

1. Single Responsibility Principle (SRP)

Definition

The Single Responsibility Principle states that a class should have only one reason to change. This means each class should have a single, well-defined responsibility and should encapsulate only one aspect of the software’s functionality.

Why SRP Matters

When a class has multiple responsibilities, changes to one responsibility can affect other unrelated functionalities. This creates tight coupling, makes the code fragile, and increases the risk of introducing bugs when making modifications.

Example: Violating SRP

Let’s consider an EmailService class that handles both email composition and sending:

Violating SRP by Sumit Kumar Singh
// BAD: Violating SRP - Multiple responsibilities
public class EmailService {
private String smtpServer;
private int port;

public EmailService(String smtpServer, int port) {
this.smtpServer = smtpServer;
this.port = port;
}

// Responsibility 1: Email content formatting
public String formatWelcomeEmail(String userName, String companyName) {
return "<html>" +
"<h1>Welcome " + userName + "!</h1>" +
"<p>Thank you for joining " + companyName + "</p>" +
"<footer>Best regards, " + companyName + " Team</footer>" +
"</html>";
}

public String formatPasswordResetEmail(String userName, String resetLink) {
return "<html>" +
"<h1>Password Reset Request</h1>" +
"<p>Hi " + userName + ", click the link below to reset your password:</p>" +
"<a href='" + resetLink + "'>Reset Password</a>" +
"</html>";
}

// Responsibility 2: Email delivery
public void sendEmail(String to, String subject, String content) {
// Connect to SMTP server
System.out.println("Connecting to " + smtpServer + ":" + port);

// Send email
System.out.println("Sending email to: " + to);
System.out.println("Subject: " + subject);
System.out.println("Content: " + content);

// Close connection
System.out.println("Email sent successfully");
}

// Responsibility 3: Email validation
public boolean isValidEmail(String email) {
return email != null &&
email.contains("@") &&
email.contains(".") &&
!email.startsWith("@") &&
!email.endsWith("@");
}
}

Problems with this approach:

  • If email formatting requirements change, we need to modify the EmailService class
  • If SMTP configuration changes, we need to modify the same class
  • If validation rules change, again we modify the same class
  • The class becomes bloated and difficult to test
  • Changes in one area might break other functionalities

Example: Following SRP

Here’s how we can refactor the code to follow SRP:

Following SRP by Sumit Kumar Singh
// GOOD: Following SRP - Single responsibility per class

// Responsibility 1: Email content formatting
public class EmailContentFormatter {

public String formatWelcomeEmail(String userName, String companyName) {
return "<html>" +
"<h1>Welcome " + userName + "!</h1>" +
"<p>Thank you for joining " + companyName + "</p>" +
"<footer>Best regards, " + companyName + " Team</footer>" +
"</html>";
}

public String formatPasswordResetEmail(String userName, String resetLink) {
return "<html>" +
"<h1>Password Reset Request</h1>" +
"<p>Hi " + userName + ", click the link below to reset your password:</p>" +
"<a href='" + resetLink + "'>Reset Password</a>" +
"</html>";
}
}

// Responsibility 2: Email validation
public class EmailValidator {

public boolean isValidEmail(String email) {
if (email == null || email.trim().isEmpty()) {
return false;
}

return email.contains("@") &&
email.contains(".") &&
!email.startsWith("@") &&
!email.endsWith("@") &&
email.indexOf("@") == email.lastIndexOf("@");
}
}

// Responsibility 3: Email delivery
public class EmailSender {
private String smtpServer;
private int port;

public EmailSender(String smtpServer, int port) {
this.smtpServer = smtpServer;
this.port = port;
}

public void sendEmail(String to, String subject, String content) {
// Connect to SMTP server
System.out.println("Connecting to " + smtpServer + ":" + port);

// Send email
System.out.println("Sending email to: " + to);
System.out.println("Subject: " + subject);
System.out.println("Content: " + content);

// Close connection
System.out.println("Email sent successfully");
}
}

// Usage example
public class EmailServiceDemo {
public static void main(String[] args) {
// Create service objects
EmailContentFormatter formatter = new EmailContentFormatter();
EmailValidator validator = new EmailValidator();
EmailSender sender = new EmailSender("smtp.gmail.com", 587);

// Use the services
String userEmail = "[email protected]";
String userName = "John Doe";

if (validator.isValidEmail(userEmail)) {
String content = formatter.formatWelcomeEmail(userName, "TechCorp");
sender.sendEmail(userEmail, "Welcome to TechCorp!", content);
} else {
System.out.println("Invalid email address: " + userEmail);
}
}
}

Benefits of the refactored code:

  • Each class has a single, well-defined responsibility
  • Changes to email formatting don’t affect validation or sending logic
  • Easy to test each component in isolation
  • High reusability — components can be used independently
  • Better maintainability and reduced risk of bugs

2. Open/Closed Principle (OCP)

Definition

The Open/Closed Principle states that software entities should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code.

Why OCP Matters

Modifying existing, tested code introduces risk. OCP allows you to add new features through extension mechanisms like inheritance, composition, and interfaces, keeping the existing codebase stable.

Example: Violating OCP

Consider a payment processing system:

Violating OCP by Sumit Kumar Singh
// BAD: Violating OCP - Must modify existing code for new payment methods
public class PaymentProcessor {

public void processPayment(String paymentType, double amount, String details) {
if (paymentType.equals("CREDIT_CARD")) {
processCreditCardPayment(amount, details);
} else if (paymentType.equals("PAYPAL")) {
processPayPalPayment(amount, details);
} else if (paymentType.equals("BANK_TRANSFER")) {
processBankTransferPayment(amount, details);
} else {
throw new IllegalArgumentException("Unsupported payment type: " + paymentType);
}
}

private void processCreditCardPayment(double amount, String cardDetails) {
System.out.println("Processing credit card payment of $" + amount);
System.out.println("Card details: " + cardDetails);
// Credit card processing logic
System.out.println("Credit card payment processed successfully");
}

private void processPayPalPayment(double amount, String email) {
System.out.println("Processing PayPal payment of $" + amount);
System.out.println("PayPal email: " + email);
// PayPal processing logic
System.out.println("PayPal payment processed successfully");
}

private void processBankTransferPayment(double amount, String accountNumber) {
System.out.println("Processing bank transfer of $" + amount);
System.out.println("Account number: " + accountNumber);
// Bank transfer processing logic
System.out.println("Bank transfer processed successfully");
}
}

In the above example all the Payment Type is getting processed by Payment Processor which is violation of OCP.

Class Diagram of Violating OCP by Sumit Kumar Singh

Problems with this approach:

  • Every time we add a new payment method, we must modify the PaymentProcessor class
  • Risk of breaking existing functionality when adding new payment types
  • Violates the “closed for modification” principle
  • Growing if-else chain makes code harder to maintain

Example: Following OCP

Here’s the refactored code that follows OCP:

Following OCP by Sumit Kumar Singh
// GOOD: Following OCP - Open for extension, closed for modification

// Abstract payment method interface
public interface PaymentMethod {
void processPayment(double amount, String details);
String getPaymentType();
}

// Concrete payment method implementations
public class CreditCardPayment implements PaymentMethod {

@Override
public void processPayment(double amount, String cardDetails) {
System.out.println("Processing credit card payment of $" + amount);
System.out.println("Card details: " + cardDetails);

// Validate card
if (validateCard(cardDetails)) {
// Process payment
System.out.println("Credit card payment processed successfully");
} else {
throw new RuntimeException("Invalid credit card details");
}
}

@Override
public String getPaymentType() {
return "Credit Card";
}

private boolean validateCard(String cardDetails) {
// Credit card validation logic
return cardDetails != null && cardDetails.length() >= 16;
}
}

public class PayPalPayment implements PaymentMethod {

@Override
public void processPayment(double amount, String email) {
System.out.println("Processing PayPal payment of $" + amount);
System.out.println("PayPal email: " + email);

if (validatePayPalAccount(email)) {
// Process payment
System.out.println("PayPal payment processed successfully");
} else {
throw new RuntimeException("Invalid PayPal account");
}
}

@Override
public String getPaymentType() {
return "PayPal";
}

private boolean validatePayPalAccount(String email) {
// PayPal account validation logic
return email != null && email.contains("@");
}
}

public class BankTransferPayment implements PaymentMethod {

@Override
public void processPayment(double amount, String accountNumber) {
System.out.println("Processing bank transfer of $" + amount);
System.out.println("Account number: " + accountNumber);

if (validateBankAccount(accountNumber)) {
// Process payment
System.out.println("Bank transfer processed successfully");
} else {
throw new RuntimeException("Invalid bank account");
}
}

@Override
public String getPaymentType() {
return "Bank Transfer";
}

private boolean validateBankAccount(String accountNumber) {
// Bank account validation logic
return accountNumber != null && accountNumber.length() >= 10;
}
}

// New payment method can be added without modifying existing code
public class CryptocurrencyPayment implements PaymentMethod {

@Override
public void processPayment(double amount, String walletAddress) {
System.out.println("Processing cryptocurrency payment of $" + amount);
System.out.println("Wallet address: " + walletAddress);

if (validateWalletAddress(walletAddress)) {
// Process payment
System.out.println("Cryptocurrency payment processed successfully");
} else {
throw new RuntimeException("Invalid wallet address");
}
}

@Override
public String getPaymentType() {
return "Cryptocurrency";
}

private boolean validateWalletAddress(String walletAddress) {
// Wallet address validation logic
return walletAddress != null && walletAddress.length() >= 26;
}
}

// Payment processor that works with any payment method
public class PaymentProcessor {

public void processPayment(PaymentMethod paymentMethod, double amount, String details) {
try {
System.out.println("Starting payment processing using " + paymentMethod.getPaymentType());
paymentMethod.processPayment(amount, details);
System.out.println("Payment completed successfully");
} catch (Exception e) {
System.out.println("Payment failed: " + e.getMessage());
}
}
}

// Usage example
public class PaymentDemo {
public static void main(String[] args) {
PaymentProcessor processor = new PaymentProcessor();

// Process different types of payments
PaymentMethod creditCard = new CreditCardPayment();
processor.processPayment(creditCard, 100.50, "1234567890123456");

PaymentMethod paypal = new PayPalPayment();
processor.processPayment(paypal, 75.25, "[email protected]");

PaymentMethod bankTransfer = new BankTransferPayment();
processor.processPayment(bankTransfer, 200.00, "1234567890");

// New payment method can be added without modifying existing code
PaymentMethod crypto = new CryptocurrencyPayment();
processor.processPayment(crypto, 50.00, "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa");
}
}
Class diagram of Following OCP by Sumit Kumar Singh

Benefits of the refactored code:

  • Open for extension: New payment methods can be added easily
  • Closed for modification: Existing code doesn’t need to change
  • Better separation of concerns
  • Easier to test individual payment methods
  • More maintainable and flexible architecture

3. Liskov Substitution Principle (LSP)

Definition

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without breaking the application. Subclasses should extend the capability of the parent class, not narrow it down.

Why LSP Matters

LSP ensures that inheritance hierarchies are logically sound and that polymorphism works correctly. When LSP is violated, client code can’t reliably work with different implementations of the same interface.

Example: Violating LSP

Consider a document processing system:

Violating LSP class diagram by Sumit Kumar Singh
// BAD: Violating LSP - Subclass changes expected behavior
public abstract class Document {
protected String content;
protected String title;

public Document(String title, String content) {
this.title = title;
this.content = content;
}

public abstract void open();
public abstract void edit(String newContent);
public abstract void save();
public abstract void print();

public String getTitle() {
return title;
}

public String getContent() {
return content;
}
}

public class EditableDocument extends Document {

public EditableDocument(String title, String content) {
super(title, content);
}

@Override
public void open() {
System.out.println("Opening editable document: " + title);
}

@Override
public void edit(String newContent) {
this.content = newContent;
System.out.println("Document content updated");
}

@Override
public void save() {
System.out.println("Saving document: " + title);
}

@Override
public void print() {
System.out.println("Printing document: " + title);
System.out.println("Content: " + content);
}
}
// This class violates LSP by throwing exceptions for inherited methods
public class ReadOnlyDocument extends Document {

public ReadOnlyDocument(String title, String content) {
super(title, content);
}

@Override
public void open() {
System.out.println("Opening read-only document: " + title);
}

// LSP Violation: Throwing exception changes expected behavior
@Override
public void edit(String newContent) {
throw new UnsupportedOperationException("Cannot edit read-only document");
}

// LSP Violation: Throwing exception changes expected behavior
@Override
public void save() {
throw new UnsupportedOperationException("Cannot save read-only document");
}

@Override
public void print() {
System.out.println("Printing read-only document: " + title);
System.out.println("Content: " + content);
}
}
// Usage example showing the problem
public class DocumentProcessorDemo {

public static void processDocuments(List<Document> documents) {
for (Document doc : documents) {
doc.open();

// This will fail for ReadOnlyDocument - LSP violation
doc.edit("Updated content");
doc.save();
doc.print();

System.out.println("---");
}
}

public static void main(String[] args) {
List<Document> documents = Arrays.asList(
new EditableDocument("Report", "Initial content"),
new ReadOnlyDocument("Manual", "Read-only content") // This will cause exceptions
);

processDocuments(documents); // Will fail due to LSP violation
}
}

Problems with this approach:

  • ReadOnlyDocument throws exceptions for edit() and save() methods
  • Client code cannot treat all Document subclasses uniformly
  • Violates the expectation that all documents can be edited and saved
  • Breaks polymorphism and makes code fragile

Example: Following LSP

Here’s the refactored code that follows LSP:

Following LSP class diagram by Sumit Kumar Singh
// GOOD: Following LSP - Proper inheritance hierarchy

// Base class with common functionality
public abstract class Document {
protected String content;
protected String title;

public Document(String title, String content) {
this.title = title;
this.content = content;
}

// Common methods that all documents can perform
public abstract void open();
public abstract void print();

public String getTitle() {
return title;
}

public String getContent() {
return content;
}
}
// Interface for editable behavior
public interface Editable {
void edit(String newContent);
boolean canEdit();
}
// Interface for saveable behavior
public interface Saveable {
void save();
boolean canSave();
}
// Read-only document implementation
public class ReadOnlyDocument extends Document {

public ReadOnlyDocument(String title, String content) {
super(title, content);
}

@Override
public void open() {
System.out.println("Opening read-only document: " + title);
}

@Override
public void print() {
System.out.println("Printing read-only document: " + title);
System.out.println("Content: " + content);
}
}
// Editable document implementation
public class EditableDocument extends Document implements Editable, Saveable {
private boolean modified = false;

public EditableDocument(String title, String content) {
super(title, content);
}

@Override
public void open() {
System.out.println("Opening editable document: " + title);
}

@Override
public void edit(String newContent) {
this.content = newContent;
this.modified = true;
System.out.println("Document content updated");
}

@Override
public boolean canEdit() {
return true;
}

@Override
public void save() {
if (modified) {
System.out.println("Saving document: " + title);
modified = false;
} else {
System.out.println("No changes to save for: " + title);
}
}

@Override
public boolean canSave() {
return modified;
}

@Override
public void print() {
System.out.println("Printing editable document: " + title);
System.out.println("Content: " + content);
}
}
// Protected document with limited editing
public class ProtectedDocument extends Document implements Editable {
private String password;
private boolean isUnlocked = false;

public ProtectedDocument(String title, String content, String password) {
super(title, content);
this.password = password;
}

@Override
public void open() {
System.out.println("Opening protected document: " + title);
}

public boolean unlock(String providedPassword) {
if (password.equals(providedPassword)) {
isUnlocked = true;
System.out.println("Document unlocked");
return true;
}
System.out.println("Incorrect password");
return false;
}

@Override
public void edit(String newContent) {
if (canEdit()) {
this.content = newContent;
System.out.println("Protected document content updated");
} else {
System.out.println("Cannot edit locked document");
}
}

@Override
public boolean canEdit() {
return isUnlocked;
}

@Override
public void print() {
System.out.println("Printing protected document: " + title);
if (isUnlocked) {
System.out.println("Content: " + content);
} else {
System.out.println("Content: [PROTECTED]");
}
}
}
// Document processor that respects LSP
public class DocumentProcessor {

public void processBasicDocuments(List<Document> documents) {
for (Document doc : documents) {
doc.open();
doc.print();
System.out.println("---");
}
}

public void processEditableDocuments(List<Document> documents) {
for (Document doc : documents) {
doc.open();

// Check if document supports editing before attempting to edit
if (doc instanceof Editable) {
Editable editableDoc = (Editable) doc;
if (editableDoc.canEdit()) {
editableDoc.edit("Updated content");
System.out.println("Document edited successfully");
} else {
System.out.println("Document cannot be edited at this time");
}
} else {
System.out.println("Document is read-only");
}

// Check if document supports saving before attempting to save
if (doc instanceof Saveable) {
Saveable saveableDoc = (Saveable) doc;
if (saveableDoc.canSave()) {
saveableDoc.save();
}
}

doc.print();
System.out.println("---");
}
}
}
// Usage example
public class DocumentDemo {
public static void main(String[] args) {
List<Document> documents = Arrays.asList(
new ReadOnlyDocument("User Manual", "This is read-only content"),
new EditableDocument("Report", "Initial report content"),
new ProtectedDocument("Confidential", "Secret information", "password123")
);

DocumentProcessor processor = new DocumentProcessor();

// All documents can be processed uniformly for basic operations
System.out.println("=== Processing all documents for basic operations ===");
processor.processBasicDocuments(documents);

// Handle editable operations with proper type checking
System.out.println("=== Processing documents for editing ===");
processor.processEditableDocuments(documents);

// Demonstrate protected document
System.out.println("=== Unlocking protected document ===");
ProtectedDocument protectedDoc = (ProtectedDocument) documents.get(2);
protectedDoc.unlock("password123");
processor.processEditableDocuments(Arrays.asList(protectedDoc));
}
}

Benefits of the refactored code:

  • All Document subclasses can be substituted without breaking functionality
  • Proper separation of concerns using interfaces
  • Client code can safely work with any document type
  • No unexpected exceptions or behavior changes
  • Clear contracts through interfaces and capability checking

4. Interface Segregation Principle (ISP)

Definition

The Interface Segregation Principle states that clients should not be forced to depend on interfaces they don’t use. Instead of one large interface, it’s better to have multiple smaller, focused interfaces.

That means, Interfaces should be such that the client should NOT implement unnecessary functions they do not need

Why ISP Matters

Large, monolithic interfaces force classes to implement methods they don’t need, leading to bloated code and unnecessary dependencies. ISP promotes focused, cohesive interfaces that are easier to implement and maintain.

Example: Violating ISP

Violating ISP class diagram by Sumit Kumar Singh
// Fat interface - forces all staff to implement irrelevant methods
public interface SchoolStaff {
void teachSubject();
void gradePapers();
void manageAccounts();
void maintainCampus();
void conductSports();
}

// Teacher is forced to implement unrelated methods
public class Teacher implements SchoolStaff {
@Override
public void teachSubject() {
System.out.println("Teaching subject...");
}

@Override
public void gradePapers() {
System.out.println("Grading papers...");
}

@Override
public void manageAccounts() {
throw new UnsupportedOperationException("Teacher cannot manage accounts!");
}

@Override
public void maintainCampus() {
throw new UnsupportedOperationException("Teacher cannot maintain campus!");
}

@Override
public void conductSports() {
throw new UnsupportedOperationException("Teacher cannot conduct sports!");
}
}

Problems:

  • Teacher is forced to implement irrelevant methods (manageAccounts, maintainCampus, conductSports).
  • Leads to UnsupportedOperationException → clear ISP violation.

Example: Following ISP

Following ISP class diagram by Sumit Kumar Singh
// Focused interfaces
public interface TeachingDuties {
void teachSubject();
void gradePapers();
}

public interface AccountsDuties {
void manageAccounts();
}

public interface MaintenanceDuties {
void maintainCampus();
}

public interface SportsDuties {
void conductSports();
}

// Teacher only does teaching
public class Teacher implements TeachingDuties {
@Override
public void teachSubject() { System.out.println("Teaching subject..."); }
@Override
public void gradePapers() { System.out.println("Grading papers..."); }
}

// Accountant only does accounts
public class Accountant implements AccountsDuties {
@Override
public void manageAccounts() { System.out.println("Managing school accounts..."); }
}

// Janitor only does maintenance
public class Janitor implements MaintenanceDuties {
@Override
public void maintainCampus() { System.out.println("Maintaining campus..."); }
}

// Coach only does sports
public class SportsCoach implements SportsDuties {
@Override
public void conductSports() { System.out.println("Conducting sports practice..."); }
}

Benefits of the refactored code:

  • Classes only implement interfaces they actually need
  • No forced implementations or exception-throwing methods
  • Clean, focused interfaces that are easy to understand
  • Better testability — each interface can be mocked independently
  • More flexible design — devices can mix and match capabilities as needed
  • Clear separation of concerns

5. Dependency Inversion Principle (DIP)

Definition

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules; both should depend on abstractions. Additionally, abstractions should not depend on details; details should depend on abstractions.

Why DIP Matters

DIP reduces coupling between different parts of your system, makes code more flexible and testable, and allows for easier maintenance and extension. It inverts the traditional dependency flow where high-level modules depend directly on low-level implementations.

Example: Violating DIP

Consider an e-commerce notification system:

Violating DIP class diagram by Sumit Kumar Singh
// BAD: Violating DIP - High-level module depends directly on low-level modules

// Low-level modules - concrete implementations
public class EmailService {
public void sendEmail(String to, String subject, String message) {
// Email sending implementation
System.out.println("Sending email to: " + to);
System.out.println("Subject: " + subject);
System.out.println("Message: " + message);
System.out.println("Email sent via SMTP");
}
}
public class SMSService {
public void sendSMS(String phoneNumber, String message) {
// SMS sending implementation
System.out.println("Sending SMS to: " + phoneNumber);
System.out.println("Message: " + message);
System.out.println("SMS sent via carrier gateway");
}
}
public class SlackService {
public void sendSlackMessage(String channel, String message) {
// Slack message implementation
System.out.println("Sending Slack message to channel: " + channel);
System.out.println("Message: " + message);
System.out.println("Message sent via Slack API");
}
}
// High-level module directly depending on low-level modules
public class OrderNotificationService {
// Direct dependencies on concrete classes - tight coupling
private EmailService emailService;
private SMSService smsService;
private SlackService slackService;

public OrderNotificationService() {
// Creating dependencies directly - violation of DIP
this.emailService = new EmailService();
this.smsService = new SMSService();
this.slackService = new SlackService();
}

public void notifyOrderConfirmation(String customerEmail, String customerPhone, String orderDetails) {
// Tightly coupled to specific implementations
String subject = "Order Confirmation";
String message = "Your order has been confirmed. Details: " + orderDetails;

emailService.sendEmail(customerEmail, subject, message);
smsService.sendSMS(customerPhone, "Order confirmed: " + orderDetails);
slackService.sendSlackMessage("#orders", "New order: " + orderDetails);
}

public void notifyOrderShipped(String customerEmail, String customerPhone, String trackingNumber) {
String subject = "Order Shipped";
String message = "Your order has been shipped. Tracking number: " + trackingNumber;

emailService.sendEmail(customerEmail, subject, message);
smsService.sendSMS(customerPhone, "Order shipped. Tracking: " + trackingNumber);
}
}
// Usage example showing the problems
public class ECommerceDemo {
public static void main(String[] args) {
OrderNotificationService notificationService = new OrderNotificationService();

// Works but is tightly coupled
notificationService.notifyOrderConfirmation(
"[email protected]",
"+1234567890",
"Order #12345: 2x T-Shirts"
);

// Problems:
// 1. Cannot easily test OrderNotificationService in isolation
// 2. Cannot switch notification providers without modifying the class
// 3. Cannot add new notification methods without changing existing code
// 4. High coupling between high-level and low-level modules
}
}

Problems with this approach:

  • OrderNotificationService is tightly coupled to specific notification implementations
  • Cannot easily switch or add new notification providers
  • Difficult to test in isolation (cannot mock dependencies)
  • High-level module depends directly on low-level modules
  • Changes in notification implementations might break the order service

Example: Following DIP

Here’s the refactored code that follows DIP:

Following DIP class diagram by Sumit Kumar Singh
// GOOD: Following DIP - Both high-level and low-level modules depend on abstractions

// Abstraction - defines the contract for notification services
public interface NotificationService {
void sendNotification(String recipient, String subject, String message);
String getNotificationType();
}
// Low-level modules implementing the abstraction
public class EmailNotificationService implements NotificationService {
private String smtpServer;
private int port;

public EmailNotificationService(String smtpServer, int port) {
this.smtpServer = smtpServer;
this.port = port;
}

@Override
public void sendNotification(String recipient, String subject, String message) {
// Email implementation
System.out.println("=== Email Notification ===");
System.out.println("SMTP Server: " + smtpServer + ":" + port);
System.out.println("To: " + recipient);
System.out.println("Subject: " + subject);
System.out.println("Message: " + message);
System.out.println("Email sent successfully");
}

@Override
public String getNotificationType() {
return "Email";
}
}
public class SMSNotificationService implements NotificationService {
private String apiKey;
private String carrierGateway;

public SMSNotificationService(String apiKey, String carrierGateway) {
this.apiKey = apiKey;
this.carrierGateway = carrierGateway;
}

@Override
public void sendNotification(String recipient, String subject, String message) {
// SMS implementation (subject is ignored for SMS)
System.out.println("=== SMS Notification ===");
System.out.println("Carrier Gateway: " + carrierGateway);
System.out.println("To: " + recipient);
System.out.println("Message: " + message);
System.out.println("SMS sent successfully");
}

@Override
public String getNotificationType() {
return "SMS";
}
}
public class SlackNotificationService implements NotificationService {
private String botToken;
private String workspaceUrl;

public SlackNotificationService(String botToken, String workspaceUrl) {
this.botToken = botToken;
this.workspaceUrl = workspaceUrl;
}

@Override
public void sendNotification(String recipient, String subject, String message) {
// Slack implementation
System.out.println("=== Slack Notification ===");
System.out.println("Workspace: " + workspaceUrl);
System.out.println("Channel: " + recipient);
System.out.println("Subject: " + subject);
System.out.println("Message: " + message);
System.out.println("Slack message sent successfully");
}

@Override
public String getNotificationType() {
return "Slack";
}
}
// New notification service can be added without modifying existing code
public class PushNotificationService implements NotificationService {
private String deviceToken;
private String appId;

public PushNotificationService(String deviceToken, String appId) {
this.deviceToken = deviceToken;
this.appId = appId;
}

@Override
public void sendNotification(String recipient, String subject, String message) {
System.out.println("=== Push Notification ===");
System.out.println("App ID: " + appId);
System.out.println("Device Token: " + deviceToken);
System.out.println("To: " + recipient);
System.out.println("Title: " + subject);
System.out.println("Message: " + message);
System.out.println("Push notification sent successfully");
}

@Override
public String getNotificationType() {
return "Push";
}
}
// High-level module depending on abstraction
public class OrderNotificationManager {
private List<NotificationService> notificationServices;

// Dependency injection through constructor - depends on abstraction
public OrderNotificationManager(List<NotificationService> notificationServices) {
this.notificationServices = new ArrayList<>(notificationServices);
}

// Method to add notification services dynamically
public void addNotificationService(NotificationService service) {
notificationServices.add(service);
System.out.println("Added " + service.getNotificationType() + " notification service");
}

// Method to remove notification services
public void removeNotificationService(String notificationType) {
notificationServices.removeIf(service ->
service.getNotificationType().equals(notificationType));
System.out.println("Removed " + notificationType + " notification service");
}

public void notifyOrderConfirmation(String orderId, String customerDetails, String orderDetails) {
String subject = "Order Confirmation - " + orderId;
String message = String.format(
"Dear %s,nnYour order has been confirmed!nnOrder Details: %snnThank you for your purchase!",
customerDetails, orderDetails
);

sendNotificationToAll(customerDetails, subject, message);
}

public void notifyOrderShipped(String orderId, String customerDetails, String trackingNumber) {
String subject = "Order Shipped - " + orderId;
String message = String.format(
"Dear %s,nnYour order has been shipped!nnTracking Number: %snnYou can track your package online.",
customerDetails, trackingNumber
);

sendNotificationToAll(customerDetails, subject, message);
}

public void notifyOrderDelivered(String orderId, String customerDetails) {
String subject = "Order Delivered - " + orderId;
String message = String.format(
"Dear %s,nnYour order has been delivered!nnWe hope you enjoy your purchase. Please consider leaving a review.",
customerDetails
);

sendNotificationToAll(customerDetails, subject, message);
}

private void sendNotificationToAll(String recipient, String subject, String message) {
if (notificationServices.isEmpty()) {
System.out.println("No notification services configured");
return;
}

System.out.println("Sending notifications through " + notificationServices.size() + " service(s):");
for (NotificationService service : notificationServices) {
try {
service.sendNotification(recipient, subject, message);
} catch (Exception e) {
System.out.println("Failed to send " + service.getNotificationType() +
" notification: " + e.getMessage());
}
}
System.out.println("All notifications sentn");
}

public void listActiveServices() {
System.out.println("Active notification services:");
for (NotificationService service : notificationServices) {
System.out.println("- " + service.getNotificationType());
}
}
}
// Configuration class that sets up dependencies
public class NotificationConfiguration {

public static List<NotificationService> createNotificationServices() {
List<NotificationService> services = new ArrayList<>();

// Create concrete implementations with their configurations
services.add(new EmailNotificationService("smtp.gmail.com", 587));
services.add(new SMSNotificationService("api-key-123", "carrier-gateway.com"));
services.add(new SlackNotificationService("bot-token-456", "company.slack.com"));

return services;
}

public static OrderNotificationManager createOrderNotificationManager() {
List<NotificationService> services = createNotificationServices();
return new OrderNotificationManager(services);
}
}
// Usage example demonstrating DIP compliance
public class ECommerceNotificationDemo {
public static void main(String[] args) {
// Create notification manager through configuration (dependency injection)
OrderNotificationManager notificationManager =
NotificationConfiguration.createOrderNotificationManager();

System.out.println("=== E-Commerce Notification System ===n");

// Show active services
notificationManager.listActiveServices();
System.out.println();

// Send order confirmation
notificationManager.notifyOrderConfirmation(
"ORD-001",
"[email protected] / +1234567890 / #general",
"2x Gaming Laptop, 1x Wireless Mouse - Total: $2,500"
);

// Send shipping notification
notificationManager.notifyOrderShipped(
"ORD-001",
"[email protected] / +1234567890 / #general",
"TRK123456789"
);

// Dynamically add a new notification service
PushNotificationService pushService = new PushNotificationService(
"device-token-789", "ecommerce-app-id"
);
notificationManager.addNotificationService(pushService);

// Send delivery notification through all services including the new one
notificationManager.notifyOrderDelivered(
"ORD-001",
"[email protected] / +1234567890 / #general / user123"
);

// Remove a service dynamically
notificationManager.removeNotificationService("SMS");

// Final notification will be sent through remaining services
notificationManager.notifyOrderConfirmation(
"ORD-002",
"[email protected] / #general / user456",
"1x Smart Watch - Total: $300"
);
}
}

Benefits of the refactored code:

  • Loose coupling: High-level module depends on abstraction, not concrete implementations
  • Easy testing: Dependencies can be easily mocked or stubbed
  • Flexible configuration: Notification services can be added or removed dynamically
  • Better maintainability: Changes to notification implementations don’t affect the order service
  • Extensibility: New notification types can be added without modifying existing code
  • Inversion of control: Dependencies are injected rather than created internally

Summary and Best Practices

The SOLID principles work together to create software that is:

1. Maintainable

  • Changes to one part don’t affect unrelated parts
  • Code is organized into focused, cohesive units
  • Dependencies are managed through abstractions

2. Testable

  • Classes have single responsibilities and can be tested in isolation
  • Dependencies can be easily mocked or stubbed
  • Interfaces define clear contracts for testing

3. Extensible

  • New functionality can be added without modifying existing code
  • Systems are open for extension but closed for modification
  • Polymorphism enables flexible behavior

4. Flexible

  • Components can be easily swapped or reconfigured
  • Loose coupling allows for independent evolution of modules
  • Abstractions hide implementation details

Key Takeaways

  1. Start with SRP: Ensure each class has a single, well-defined responsibility
  2. Design for extension: Use OCP to allow new features without breaking existing code
  3. Respect contracts: LSP ensures that inheritance hierarchies are logically sound
  4. Keep interfaces focused: ISP prevents bloated interfaces and unnecessary dependencies
  5. Depend on abstractions: DIP creates flexible, loosely coupled systems

When to Apply SOLID Principles

  • During design phase: Consider SOLID principles when architecting new systems
  • During refactoring: Use SOLID principles to improve existing codebases
  • When adding features: Ensure new functionality follows SOLID principles
  • In code reviews: Check that changes adhere to SOLID principles

Remember, SOLID principles are guidelines, not rigid rules. Apply them judiciously based on your specific context, requirements, and constraints. The goal is to create code that is easier to understand, maintain, and extend over time.

Follow Me for More Content on modern Java, Spring Boot, and System Design. Happy coding!!!

If you found this guide helpful, I’d love to connect with you and share more Java development insights!

Connect With Me

LinkedIn, GitHub, Twitter/X, Medium


A Solid Guide to SOLID Principles was originally published in Javarevisited on Medium, where people are continuing the conversation by highlighting and responding to this story.

This post first appeared on Read More