image blog solid object oriented design principles
April 29, 2021

SOLID Design Principles in Java Application Development

DevOps
Java Application Development

SOLID refers to five design principles in object-oriented programming, designed to reduce code rot and improve the value, function, and maintainability of software. The SOLID principles help the user develop less coupled code. If code is tightly coupled, a group of classes are dependent on one another. This should be avoided for better maintainability and readability.

Here, we give an overview of each SOLID principle along with an example of each.

Back to top

What Are SOLID Design Principles?

SOLID design is an acronym for five principles that serve as a valuable standard. SOLID principles are class-level, object-oriented design concepts that, in conjunction with an extensive test suite, help you avoid code rot. 

SOLID design is an acronym for the following five principles:

These principles provide a valuable standard for guiding developers away from such “code rot," and instead towards building applications that provide lasting value for customers and sanity for future developers working on your project.

Back to top

SOLID Design Principles

1. Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) states that there should never be more than one reason for a class to change. This means that every class, or similar structure, in your code should have only one job to do.

Everything in the class should be related to that single purpose. It does not mean that your classes should only contain one method or property.

There can be a lot of members as long as they relate to the single responsibility. It may be that when the one reason to change occurs, multiple members of the class may need modification. It may also be that multiple classes will require updates.

For example:

class Employee {
public Pay calculatePay() {...}
public void save() {...}
public String describeEmployee() {...}
}

Here we have pay calculation logic with database logic and reporting logic all mixed up within one class. If you have multiple responsibilities combined into one class, it might be difficult to change one part without breaking others.

Mixing responsibilities also makes the class harder to understand and harder to test, decreasing cohesion. The easiest way to fix this is to split the class into three different classes, with each having only one responsibility: database access, calculating pay, and reporting, all separated.

2. Open-Closed Principle (OCP)

The Open-Closed Principle (OCP) states that classes should be open for extension but closed for modification. “Open to extension” means that you should design your classes so that new functionality can be added as new requirements are generated. “Closed for modification” means that once you have developed a class you should never modify it, except to correct bugs.

These two parts of the principle appear to be contradictory. However, if you correctly structure classes and their dependencies, you can add functionality without editing existing source code.

Generally you achieve this by referring to abstractions for dependencies, such as interfaces or abstract classes, rather than using concrete classes. Functionality can be added by creating new classes that implement the interfaces.

Applying OCP to your projects limits the need to change source code once it has been written, tested, and debugged. This reduces the risk of introducing new bugs to existing code, leading to more robust software.

Open-Closed Principle Example

Another side effect of the use of interfaces for dependencies is reduced coupling (i.e. when changing code in class A forces related changes in class B), and increased flexibility.

void checkOut(Receipt receipt) {
  Money total = Money.zero;
  for (item : items) {
    total += item.getPrice();
    receipt.addItem(item);
  }
  Payment p = acceptCash(total);
  receipt.addPayment(p);
}

So how do we add credit card support? You could add an “if” statement like this, but then that would be violation of OCP.

Payment p;
if (credit)
  p = acceptCredit(total);
else
  p = acceptCash(total);
receipt.addPayment(p);

Here is a better solution:

public interface PaymentMethod {void acceptPayment(Money total);}

void checkOut(Receipt receipt, PaymentMethod pm) {
  Money total = Money.zero;
  for (item : items) {
    total += item.getPrice();
    receipt.addItem(item);
  }
  Payment p = pm.acceptPayment(total);
  receipt.addPayment(p);
}

Keep in mind – OCP helps only if the changes that are going to come are predictable, so you should apply it only if a similar change has already happened. So, first do the simplest thing and then see what changes are requested so you can more accurately predict the future changes.

This means waiting for a customer to make a change and then invent the abstractions that will protect yourself from a similar change in the future.

3. Liskov Substitution Principles (LSP)

The Liskov Substitution Principle (LSP) applies to inheritance hierarchies, specifying that you should design your classes so that client dependencies can be substituted with subclasses without the client knowing about the change.

All subclasses must, therefore, operate in the same manner as their base classes. The specific functionality of the subclass may be different but must conform to the expected behavior of the base class. To be a true behavioral subtype, the subclass must not only implement the base class’s methods and properties, but also conform to its implied behavior.

In general, if a subtype of the supertype does something that the client of the supertype does not expect, then this is in violation of LSP. Imagine a derived class throwing an exception that the superclass does not throw, or if a derived class has some unexpected side effects. Basically, derived classes should never do less than their base class.

A typical example that violates LSP is a Square class that derives from a Rectangle class. The Square class always assumes that the width is equal with the height. If a Square object is used in a context where a Rectangle is expected, unexpected behavior may occur because the dimensions of a Square cannot (or rather should not) be modified independently.

Liskov Substitution Principle Example

This problem cannot be easily fixed: if we can modify the setter methods in the Square class so that they preserve the Square invariant (i.e., keep the dimensions equal), then these methods will weaken (violate) the post-conditions for the Rectangle setters, which state that dimensions can be modified independently.

public class Rectangle {
  private double height;
  private double width;

  public double area();

  public void setHeight(double height);
  public void setWidth(double width);
}

What you see above violates LSP.

public class Square extends Rectangle {  
  public void setHeight(double height) {
    super.setHeight(height);
    super.setWidth(height);
  }

  public void setWidth(double width) {
    setHeight(width);
  }
}

Violations of LSP cause undefined behavior, which can lead to weeks of wasted time trying to find out where the bug is.

4. Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) states that clients should not be forced to depend upon interface members they do not use. When we have non-cohesive interfaces, the ISP guides us to create multiple, smaller, cohesive interfaces.

When you apply ISP, classes and their dependencies communicate using tightly-focused interfaces, minimizing dependencies on unused members and reducing coupling accordingly. Smaller interfaces are easier to implement, improving flexibility and the possibility of reuse. As fewer classes share these interfaces, the number of changes that are required in response to an interface modification is lowered, which increases robustness.

Interface Segregation Principle Example

Picture an ATM, which has a screen where we wish to display different messages. If you want to add a message on the ATM that appears only for withdrawal functionality, how would you solve it?

Perhaps you would add a method to the Messenger interface and be done with it. But this causes you to recompile all the users of this interface and almost all the system needs to be redeployed, which is in direct violation of OCP. 

What happened here was that changing the withdrawal functionality caused changes to other totally unrelated functionalities as well, which is something we now know we don't want. How did this happen?

Actually, there is backwards dependency at play, where each functionality that uses this Messengers interface depends on methods it does not need but are needed by other functionalities. Here is what we want to avoid:

public interface Messenger {
  askForCard();
  tellInvalidCard();
  askForPin();
  tellInvalidPin();
  tellCardWasSiezed();
  askForAccount();
  tellNotEnoughMoneyInAccount();
  tellAmountDeposited();
  tellBalance();
}

Instead, split the Messenger interface up so that different ATM functionality depend on separate Messengers.

public interface LoginMessenger {
  askForCard();
  tellInvalidCard();
  askForPin();
  tellInvalidPin();	
}

public interface WithdrawalMessenger {
  tellNotEnoughMoneyInAccount();
  askForFeeConfirmation();
}

publc class EnglishMessenger implements LoginMessenger, WithdrawalMessenger {
  ...	
}

5. Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) states that high-level modules should not depend upon low-level modules; they should depend on abstractions.

Secondly, abstractions should not depend upon details; details should depend upon abstractions. The idea is that we isolate our class behind a boundary formed by the abstractions it depends on. If all the details behind those abstractions change, then our class is still safe. This helps keep coupling low and makes our design easier to change. DIP also allows us to test things in isolation.

Dependency Inversion Principle Example

Example: A program depends on Reader and Writer interfaces that are abstractions, and Keyboard and Printer are details that depend on those abstractions by implementing those interfaces. Here CharCopier is oblivious to the low-level details of Reader and Writer implementations and thus you can pass in any Device that implements the Reader and Writer interface and CharCopier would still work correctly.

public interface Reader { char getchar(); }
public interface Writer { void putchar(char c)}

class CharCopier {

  void copy(Reader reader, Writer writer) {
    int c;
    while ((c = reader.getchar()) != EOF) {
      writer.putchar();
    }
  }
}

public Keyboard implements Reader {...}
public Printer implements Writer {…}
Back to top

Final Thoughts

SOLID principles are valuable tools in your toolbox, ones you should keep them in back of your mind when designing your next feature or application. Another helpful tool to use in development is JRebel, a JVM plugin that eliminates redeploy times. 

Looking to save time on development? Developers save an average of 10% Java development time annually with JRebel. See for yourself during your 14-day free trial. 

Try Free      Schedule a quick demo

Note: This post was originally published on March 18, 2014 and has been updated for accuracy and comprehensiveness.

Back to top