The Single Responsibility Principle (SRP) is the first and arguably most foundational of the five SOLID principles in object-oriented design. It states:
"A class should have only one reason to change." — Robert C. Martin (Uncle Bob)
This means that a class should have only one job or responsibility. If a class handles multiple responsibilities, changes in one area can inadvertently affect other parts of the class, making it harder to understand, test, and maintain.
As applications grow, so does the complexity of their codebases. Without clear separation of concerns, classes can become "God classes" — bloated, entangled blocks of logic that try to do too much. These are difficult to test, reuse, and extend. Adhering to SRP helps:
You can identify SRP violations by looking at the reasons a class might change. For example, consider a class that:
Each of these responsibilities could change independently — logging format might change, database schema could evolve, or validation rules could be updated. Keeping all that logic in one class violates SRP.
Let’s consider a simple ReportManager
class:
public class ReportManager {
public void generateReport() {
// Logic to generate report
}
public void saveToFile(String reportData) {
// Logic to save report to file
}
public void sendEmail(String reportData) {
// Logic to send the report via email
}
}
This class handles:
These are three separate responsibilities. A change in email logic shouldn't impact report generation.
We can refactor the above into focused classes:
public class ReportGenerator {
public String generateReport() {
// Logic to generate report
return "report content";
}
}
public class ReportSaver {
public void saveToFile(String reportData) {
// Logic to save to file
}
}
public class EmailSender {
public void sendEmail(String reportData) {
// Logic to send email
}
}
Now, each class has a single responsibility and can change independently. We can combine them in a coordinator class:
public class ReportService {
private ReportGenerator generator = new ReportGenerator();
private ReportSaver saver = new ReportSaver();
private EmailSender sender = new EmailSender();
public void processReport() {
String report = generator.generateReport();
saver.saveToFile(report);
sender.sendEmail(report);
}
}
This design is modular, extensible, and easy to maintain.
// ReportGenerator.java
class ReportGenerator {
public String generateReport() {
System.out.println("Generating report...");
return "Report Content: Sales Data for Q2";
}
}
// ReportSaver.java
class ReportSaver {
public void saveToFile(String reportData) {
System.out.println("Saving report to file...");
// Simulate file saving
System.out.println("Report saved: " + reportData);
}
}
// EmailSender.java
class EmailSender {
public void sendEmail(String reportData) {
System.out.println("Sending report via email...");
// Simulate sending email
System.out.println("Email sent with report: " + reportData);
}
}
// ReportService.java (Coordinator)
class ReportService {
private ReportGenerator generator = new ReportGenerator();
private ReportSaver saver = new ReportSaver();
private EmailSender sender = new EmailSender();
public void processReport() {
String report = generator.generateReport();
saver.saveToFile(report);
sender.sendEmail(report);
}
}
// Main.java
public class Main {
public static void main(String[] args) {
ReportService service = new ReportService();
service.processReport();
}
}
With SRP in place, unit tests become much easier to write and maintain:
ReportGenerator
without worrying about file I/O or email configuration.ReportService
for integration-style tests.For instance, if the email format changes, only EmailSenderTest
needs to be updated.
When a bug is reported in the way reports are saved, you can directly inspect ReportSaver
, knowing it’s the only class handling that concern. Without SRP, you might have to wade through a large class with unrelated logic, making bug tracking more difficult and time-consuming.
The Single Responsibility Principle encourages modular, focused class design. When applied effectively, it leads to software that is easier to maintain, test, and evolve. It may initially seem like SRP leads to more classes, but this modularity is a strength, not a weakness. By assigning one responsibility per class, your code becomes a flexible and resilient foundation for long-term software development.
Thought Exercise:
Look at a class from one of your recent projects. How many different things is it responsible for? Could you split it into smaller, more focused classes to better follow SRP?
The Open/Closed Principle (OCP) is the second principle in the SOLID acronym and a cornerstone of maintainable software design. It states:
"Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification." — Bertrand Meyer
At its core, the Open/Closed Principle advocates that once a class is written and tested, it should not be changed. Instead, new functionality should be added by extending the existing code rather than modifying it.
When software requirements evolve, developers face a key question: how can we adapt existing code without breaking it? The Open/Closed Principle provides an answer by encouraging designs that allow for behavioral extensions through polymorphism rather than rewriting working code.
By doing so, it:
Consider a basic PaymentProcessor
class:
public class PaymentProcessor {
public void processPayment(String type) {
if (type.equals("credit")) {
// logic for credit card
} else if (type.equals("paypal")) {
// logic for PayPal
} else {
throw new IllegalArgumentException("Unsupported payment type");
}
}
}
If a new payment type (e.g., Bitcoin) is introduced, this class must be modified. Every modification increases the risk of bugs and tightens coupling.
We can refactor the PaymentProcessor
to comply with OCP using an interface:
public interface PaymentMethod {
void pay();
}
Now, define implementations:
public class CreditCardPayment implements PaymentMethod {
public void pay() {
System.out.println("Processing credit card payment.");
}
}
public class PayPalPayment implements PaymentMethod {
public void pay() {
System.out.println("Processing PayPal payment.");
}
}
And a flexible processor:
public class PaymentProcessor {
public void processPayment(PaymentMethod method) {
method.pay();
}
}
To add support for a new type like Bitcoin, you simply implement the PaymentMethod
interface:
public class BitcoinPayment implements PaymentMethod {
public void pay() {
System.out.println("Processing Bitcoin payment.");
}
}
No changes to the PaymentProcessor
class are necessary. The system is now open for extension (new payment methods) but closed for modification (no touching of core logic).
// PaymentMethod.java
interface PaymentMethod {
void pay();
}
// CreditCardPayment.java
class CreditCardPayment implements PaymentMethod {
public void pay() {
System.out.println("Processing credit card payment.");
}
}
// PayPalPayment.java
class PayPalPayment implements PaymentMethod {
public void pay() {
System.out.println("Processing PayPal payment.");
}
}
// BitcoinPayment.java
class BitcoinPayment implements PaymentMethod {
public void pay() {
System.out.println("Processing Bitcoin payment.");
}
}
// PaymentProcessor.java
class PaymentProcessor {
public void processPayment(PaymentMethod method) {
method.pay();
}
}
// Main.java
public class Main {
public static void main(String[] args) {
PaymentProcessor processor = new PaymentProcessor();
PaymentMethod creditCard = new CreditCardPayment();
PaymentMethod paypal = new PayPalPayment();
PaymentMethod bitcoin = new BitcoinPayment();
processor.processPayment(creditCard); // Output: Processing credit card payment.
processor.processPayment(paypal); // Output: Processing PayPal payment.
processor.processPayment(bitcoin); // Output: Processing Bitcoin payment.
}
}
Java provides several tools to help developers apply OCP:
While OCP provides many advantages, there are some trade-offs:
Best Practice: Apply OCP when you anticipate frequent changes in behavior, especially in parts of the system that vary independently. Use abstraction only when it solves a real problem, not preemptively.
The Open/Closed Principle is a guiding philosophy for designing systems that are resilient to change. By programming to interfaces and abstracting varying behavior, developers can extend systems without modifying existing code, resulting in designs that are robust, flexible, and easier to maintain.
Thought Prompt:
Think of a class you've modified multiple times to add new logic. Could it be refactored using OCP to delegate responsibilities to extendable components?
The Liskov Substitution Principle (LSP) is the third principle in the SOLID acronym and plays a pivotal role in designing robust, extensible object-oriented systems. It was introduced by Barbara Liskov in 1987 and can be formally stated as:
“If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the correctness of the program.”
In simpler terms, a subclass should be usable anywhere its superclass is expected without causing incorrect behavior. This principle ensures the integrity of inheritance hierarchies and enables polymorphism to work as intended.
Polymorphism is one of the key advantages of object-oriented programming. It allows developers to write flexible, reusable code that operates on general types (like interfaces or abstract classes), while relying on the behavior of concrete implementations at runtime.
If a subclass breaks the expected behavior of a superclass, it compromises the reliability of that substitution. This leads to subtle bugs, tight coupling, and violations of expected contracts.
To honor LSP, a subclass must adhere to the behavioral contract defined by its superclass. That means:
Let’s see how this works through examples.
Suppose we have a base class Bird
:
public class Bird {
public void fly() {
System.out.println("The bird flies.");
}
}
Now, we subclass it with Sparrow
:
public class Sparrow extends Bird {
@Override
public void fly() {
System.out.println("The sparrow flutters and flies.");
}
}
This subclass doesn’t change the contract. A Sparrow
can be treated as a Bird
and it still behaves appropriately. It honors the expectations set by the superclass.
public void makeBirdFly(Bird b) {
b.fly();
}
This will work correctly whether b
is a Bird
or a Sparrow
.
Now let’s add another subclass:
public class Ostrich extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Ostriches can't fly!");
}
}
This subclass violates the contract of Bird
. While Bird
promises that fly()
is a valid operation, Ostrich
breaks that promise. Substituting an Ostrich
for a Bird
could crash the program:
Bird b = new Ostrich();
b.fly(); // Throws exception — violates LSP
Here, the behavior of Ostrich
contradicts the expectation that all Bird
s can fly, making it an improper subclass.
Refactor incorrectly modeled hierarchies: If Ostrich
cannot fly, maybe Bird
shouldn’t have a fly()
method in the base class.
FlyingBird
subclass and separate flight logic there.Favor composition over inheritance: If behavior varies drastically between types, consider using strategy patterns or interfaces instead.
Use interfaces to enforce appropriate contracts: Let each type declare only the capabilities it truly supports.
Example refactoring:
public interface Flyable {
void fly();
}
public class Sparrow implements Flyable {
public void fly() {
System.out.println("Sparrow flies.");
}
}
public class Ostrich {
// Doesn't implement Flyable — correct modeling
}
Now, a method that requires a flying creature can safely depend on Flyable
:
public void makeFly(Flyable f) {
f.fly();
}
Ignoring the Liskov Substitution Principle leads to:
Maintaining the behavioral contract is essential for scalable and maintainable design.
The Liskov Substitution Principle protects your code from the silent failures of poorly designed inheritance. By ensuring that subclasses remain faithful to the behavior promised by their superclasses, you build trustworthy, predictable, and modular systems. When inheritance doesn’t naturally fit, remember: composition and interfaces offer better alternatives.
The Interface Segregation Principle (ISP) is the fourth principle of SOLID design and addresses the size and design of interfaces. It states:
“Clients should not be forced to depend on methods they do not use.”
In other words, interfaces should be specific and focused on what a client needs, rather than being large and general-purpose. When interfaces become too large—sometimes referred to as “fat” interfaces—they tend to force implementing classes to define methods that may be irrelevant or unused. This leads to bloated, brittle designs and breaks the goal of modularity.
Consider the following interface:
public interface Machine {
void print();
void scan();
void fax();
}
This Machine
interface bundles multiple responsibilities. Now suppose you have a simple printer that only prints documents:
public class BasicPrinter implements Machine {
@Override
public void print() {
System.out.println("Printing document...");
}
@Override
public void scan() {
// Not supported
throw new UnsupportedOperationException("Scan not supported");
}
@Override
public void fax() {
// Not supported
throw new UnsupportedOperationException("Fax not supported");
}
}
The BasicPrinter
is being forced to implement methods it doesn’t support, violating ISP. This can lead to runtime errors, cluttered code, and maintenance challenges.
To fix this, we can break the Machine
interface into more specific interfaces, each aligned to a particular responsibility:
public interface Printer {
void print();
}
public interface Scanner {
void scan();
}
public interface Fax {
void fax();
}
Now, BasicPrinter
can implement only what it supports:
public class BasicPrinter implements Printer {
@Override
public void print() {
System.out.println("Printing document...");
}
}
A more advanced device can implement multiple interfaces:
public class MultiFunctionPrinter implements Printer, Scanner, Fax {
@Override
public void print() {
System.out.println("Printing...");
}
@Override
public void scan() {
System.out.println("Scanning...");
}
@Override
public void fax() {
System.out.println("Faxing...");
}
}
This approach offers cleaner code and stronger contracts—each class commits only to what it can actually do.
Imagine an interface like this:
public interface Animal {
void walk();
void swim();
void fly();
}
Clearly, not all animals do all these things. Let’s apply ISP:
public interface Walkable {
void walk();
}
public interface Swimmable {
void swim();
}
public interface Flyable {
void fly();
}
Now we can create animals with accurate capabilities:
public class Dog implements Walkable, Swimmable {
public void walk() {
System.out.println("Dog walking");
}
public void swim() {
System.out.println("Dog swimming");
}
}
public class Bird implements Walkable, Flyable {
public void walk() {
System.out.println("Bird walking");
}
public void fly() {
System.out.println("Bird flying");
}
}
Each class implements only the behaviors relevant to it—no unnecessary baggage.
// Walkable.java
interface Walkable {
void walk();
}
// Swimmable.java
interface Swimmable {
void swim();
}
// Flyable.java
interface Flyable {
void fly();
}
// Dog.java
class Dog implements Walkable, Swimmable {
public void walk() {
System.out.println("Dog walking");
}
public void swim() {
System.out.println("Dog swimming");
}
}
// Bird.java
class Bird implements Walkable, Flyable {
public void walk() {
System.out.println("Bird walking");
}
public void fly() {
System.out.println("Bird flying");
}
}
// Main.java
public class Main {
public static void main(String[] args) {
Dog dog = new Dog();
dog.walk(); // Output: Dog walking
dog.swim(); // Output: Dog swimming
Bird bird = new Bird();
bird.walk(); // Output: Bird walking
bird.fly(); // Output: Bird flying
}
}
The Interface Segregation Principle reinforces the value of focused, client-specific interfaces. By avoiding fat interfaces and breaking responsibilities into smaller, composable interfaces, your code becomes more modular, reusable, and easier to maintain. ISP promotes flexible architecture that’s easier to scale and refactor as systems grow in complexity.
Before creating an interface, always ask: Who will use this, and which methods do they actually need? Let that answer guide your design.
The Dependency Inversion Principle (DIP) is the fifth and final principle in the SOLID design framework. It focuses on restructuring dependencies in a way that leads to flexible and maintainable software systems.
"High-level modules should not depend on low-level modules. Both should depend on abstractions." "Abstractions should not depend on details. Details should depend on abstractions."
Traditionally, in procedural or tightly coupled designs, high-level modules (such as business logic) directly depend on low-level modules (like file readers, database connectors, or APIs). This setup makes the codebase brittle—any change to a low-level detail ripples upward, breaking business logic and creating rigid structures.
For example:
public class ReportService {
private FileWriter fileWriter = new FileWriter();
public void generateReport() {
// Logic to generate report
fileWriter.write("report.txt", "Report content");
}
}
Here, ReportService
(a high-level module) depends directly on FileWriter
(a low-level module). If we wanted to switch to a DatabaseWriter
or a CloudStorageWriter
, we’d have to modify ReportService
—clearly a violation of the Open/Closed Principle too.
To apply DIP, we introduce an interface or abstract class that defines the contract for writing:
public interface Writer {
void write(String destination, String content);
}
Then the low-level classes implement the abstraction:
public class FileWriter implements Writer {
@Override
public void write(String destination, String content) {
System.out.println("Writing to file: " + destination);
// File writing logic
}
}
public class DatabaseWriter implements Writer {
@Override
public void write(String destination, String content) {
System.out.println("Writing to database: " + content);
// DB logic
}
}
Now, ReportService
depends only on the Writer
interface:
public class ReportService {
private Writer writer;
public ReportService(Writer writer) {
this.writer = writer;
}
public void generateReport() {
String report = "Report content";
writer.write("report.txt", report);
}
}
This decouples the high-level logic from low-level details. Swapping implementations becomes trivial and doesn't require modifying core logic:
Writer writer = new FileWriter();
ReportService service = new ReportService(writer);
service.generateReport();
By depending on abstractions, your classes are not hard-wired to specific implementations. This promotes flexibility in choosing and changing behaviors.
With abstractions, it’s easy to inject mock or stub implementations during unit testing:
public class MockWriter implements Writer {
public void write(String destination, String content) {
System.out.println("Mock writer used for test.");
}
}
This enables you to test ReportService
in isolation without needing actual files or databases.
// Writer.java
interface Writer {
void write(String destination, String content);
}
// FileWriter.java
class FileWriter implements Writer {
@Override
public void write(String destination, String content) {
System.out.println("Writing to file: " + destination);
System.out.println("Content: " + content);
}
}
// DatabaseWriter.java
class DatabaseWriter implements Writer {
@Override
public void write(String destination, String content) {
System.out.println("Writing to database: " + content);
}
}
// ReportService.java
class ReportService {
private Writer writer;
public ReportService(Writer writer) {
this.writer = writer;
}
public void generateReport() {
String report = "Report content";
writer.write("report.txt", report);
}
}
// MockWriter.java (for testing)
class MockWriter implements Writer {
public void write(String destination, String content) {
System.out.println("Mock writer used for test. Skipping actual write.");
}
}
// Main.java
public class Main {
public static void main(String[] args) {
// Real usage
Writer fileWriter = new FileWriter();
ReportService fileReportService = new ReportService(fileWriter);
fileReportService.generateReport();
System.out.println();
// Test usage
Writer mockWriter = new MockWriter();
ReportService mockService = new ReportService(mockWriter);
mockService.generateReport();
}
}
Adding a new type of writer (e.g., CloudStorageWriter
) doesn’t affect existing logic. The system scales horizontally, making it future-proof and easier to extend.
In practice, DIP often manifests through Dependency Injection (DI)—a technique where dependencies (e.g., implementations of interfaces) are passed to a class rather than instantiated within it.
There are three common types of DI:
Modern frameworks like Spring automate this process using annotations like @Autowired
to inject dependencies.
Think of a high-level kitchen appliance (e.g., a blender) that can accept different attachments (blades, jars) via a standardized socket. The blender doesn’t care what brand or type of blade is used—as long as it fits the contract (interface). This is Dependency Inversion in action: the blender (high-level) and blade (low-level) depend on a common contract (socket shape/interface).
The Dependency Inversion Principle encourages designing around abstractions, rather than concrete implementations. This practice leads to software that is easier to test, extend, and maintain. By combining DIP with dependency injection techniques and solid interface design, Java developers can build robust, modular, and adaptable systems—essential traits for modern software engineering.
Before you instantiate a class inside your core logic, ask yourself: Can I depend on an interface instead? That question lies at the heart of mastering DIP.