Index

SOLID Principles in Java

Java Object-Oriented Design

11.1 Single Responsibility Principle

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.

Why SRP Matters

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:

Identifying Multiple Responsibilities

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.

Example: A Class Violating 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:

  1. Generating reports
  2. Saving reports
  3. Emailing reports

These are three separate responsibilities. A change in email logic shouldn't impact report generation.

Refactoring to Follow SRP

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.

Click to view full runnable Code

// 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();
    }
}

SRP and Testing

With SRP in place, unit tests become much easier to write and maintain:

For instance, if the email format changes, only EmailSenderTest needs to be updated.

SRP and Debugging

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.

Conclusion

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?

Index

11.2 Open/Closed Principle

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.

Why the Open/Closed Principle Matters

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:

Classic Example: Without OCP

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.

Applying OCP with Abstraction

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).

Click to view full runnable Code

// 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.
    }
}

Tools for Applying OCP

Java provides several tools to help developers apply OCP:

Benefits of the Open/Closed Principle

Trade-Offs and Misuse

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.

Conclusion

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?

Index

11.3 Liskov Substitution Principle

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.

Why Substitutability Matters

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.

Understanding Behavioral 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.

Example: Obeying LSP

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.

Example: Violating LSP

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 Birds can fly, making it an improper subclass.

How to Avoid Violating LSP

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();
}

Consequences of Violating LSP

Ignoring the Liskov Substitution Principle leads to:

Maintaining the behavioral contract is essential for scalable and maintainable design.

Summary

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.

Index

11.4 Interface Segregation Principle

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.

The Problem with Fat Interfaces

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.

Applying ISP: Split Interfaces by Responsibility

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.

Benefits of Interface Segregation

Practical Example: Animal Behaviors

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.

Click to view full runnable Code

// 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
    }
}

Conclusion

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.

Index

11.5 Dependency Inversion Principle

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."

The Problem with Direct Dependencies

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.

DIP: Invert the Dependency with Abstractions

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();

Benefits of DIP

Looser Coupling

By depending on abstractions, your classes are not hard-wired to specific implementations. This promotes flexibility in choosing and changing behaviors.

Improved Testability

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.

Click to view full runnable Code

// 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();
    }
}

Scalability and Maintenance

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.

Aligns with Other SOLID Principles

Dependency Injection as a Realization of DIP

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.

Real-World Analogy

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).

Conclusion

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.

Index