SOLID is an acronym describing five basic principles of object-oriented design that help create readable, flexible, and maintainable code. These principles were proposed by Robert C. Martin (Uncle Bob).
- S: Single Responsibility Principle – each class should have one responsibility.
- O: Open/Closed Principle – the code should be open for extension but closed for modification.
- L: Liskov Substitution Principle – objects of subclasses should be able to replace objects of base classes.
- I: Interface Segregation Principle – create specific interfaces instead of one large interface.
- D: Dependency Inversion Principle – dependencies in the system should be inverted, so higher-level modules do not depend on lower-level ones.
SPR – the principle of single responsibility
Each module and class should be responsible for a single functionality provided by the software, and this responsibility should be fully encapsulated within that class, module, or function. A class should have one and only one reason to change.
Example of bad code
public class Addresss {
String address;
public Addresss(String address) {
this.address = address;
}
public void showAddress(){
System.out.println("Address");
}
public void checkPostCode(){
System.out.println("Post Code verification");
}
public void saveAddressToTheFile(){
System.out.println("Saving address to the file");
}
}
The Address class is responsible for several aspects – it displays the address, checks if the postal code is entered correctly, and saves the address to the database. After the improvement, the code should look as follows:
public class Address {
private String address;
private FileManager fileManager;
private ValidatorPostCode validatorPostCode;
public Address(String address) {
this.address = address;
this.fileManager = new FileManager();
this.validatorPostCode = new ValidatorPostCode();
}
public void showAddress(){
System.out.println("Address");
}
public boolean checkPostCode(){
this.validatorPostCode.checkPostCode(address);
}
public void saveAddressToTheFile(){
this.fileManager.saveAddressToTheFile(address);
}
}
class ValidatorPostCode{
public boolean checkPostCode(String address){
......
return boolean
}
}
class FileManager{
public void saveAddressToTheFile(String address){
System.out.println("Saving address to the file");
}
}
OCP – open/close
Software elements (classes, modules, functions, etc.) should be open for extension but closed for modification.
Here we have an example of bad code. We are calculating the area of a collection of shapes. If we wanted to add a new shape, we would need to modify the CalculateArea class to add, for example, a triangle.
public class Shape {
public String type;
public Shape(String type) {
this.type = type;
}
}
class Rectangle extends Shape {
public double height;
public double width;
public Rectangle(double height, double width) {
super();
this.height = height;
this.width = width;
}
}
class Circle extends Shape {
public double radius;
public Circle(double radius) {
super();
this.radius = radius;
}
}
public class AreaCalculator {
public double calculateArea(List<Shape> shapes) {
double area = 0;
for(Shape shape : shapes){
if(shape.type.equals("rectangle")){
Rectangle rectangle = (Rectangle) shape;
area += rectangle.width * rectangle.height;
} else if(shape.type.equals("circle")){
Circle circle = (Circle) shape;
area += Math.PI *circle.radius*circle.radius;
}
}
return area;
}
}
To adhere to the Open/Closed Principle, we introduce the Shape interface. In this example, we can add a new shape (e.g., a triangle) without modifying the AreaCalculator class.
public Interface Shape {
double calculateArea();
}
class Rectangle implements Shape {
private double height;
private double width;
public Rectangle(double height, double width) {
this.height = height;
this.width = width;
}
double calculateArea() {
return height * width;
}
}
class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
double calculateArea() {
return Math.PI * radius * radius;
}
}
public class AreaCalculator {
public double calculateArea(List<Shape> shapes) {
double area = 0;
for(Shape shape : shapes){
area += shape.calculateArea();
}
return area;
}
}
LSP – Liskov substitution principle
If class S (subtype) is a child of class T (type), then objects of type T can be replaced with objects of type S without violating the program’s functionality. This can be achieved when the derived classes maintain compatibility with all methods. Below is an example of code that does not adhere to this relationship.
class Bird {
public void fly(){
System.out.println("I am flying..");
}
public void changeFeathers(){
System.out.println("I am changing feathers..");
}
}
class Crow extends Bird {}
class Crane extends Bird {}
class Stork extends Bird {}
class Penguin extends Bird {
public void moveAndSwim(){}
}
public class Main{
public static void main(String[] args) {
Bird bird = new Penguin();
bird.fly();//This will throw error penguins don't fly nie maja tej metody
}
}
The example presents birds. Every bird should be able to fly. However, a penguin cannot fly, so this code violates the Liskov Substitution Principle. To make this example correct, we can introduce an additional class for birds that swim in water.
class FlyingBird {
public void fly(){
System.out.println("I am flying..");
}
}
class SwimingBird {
public void moveAndSwim(){
System.out.println("I am moving and swiming..");
}
}
class Crow extends FlyingBird {}
class Crane extends FlyingBird {}
class Stork extends FlyingBird {}
class Penguin extends SwimingBird {}
public class Main{
public static void main(String[] args) {
FlyingBird bird = new Stork();
bird.fly();
SwimingBird bird2 = new Penguin();
bird2.moveAndSwim();
}
ISP – interface segregation principle
Clients should not be forced to depend on interfaces they do not use, and many dedicated interfaces are better than one general one. We should not implement methods in a class that the class does not use. Below is an example of a bad, overly broad interface.
public interface Employee {
void writeRaport();
void doFactoryJob();
void payInvoice();
}
class Accountant implements Employee {
@Override
public void writeRaport() {
}
@Override
public void doFactoryJob() {
throw new UnsupportedOperationException("Not supported yet.");
}
@Override
public void payInvoice() {
}
}
Aby go przerobić musimy stworzyć dwa oddzielne interfejsy.
public interface OfficeEmployee {
void writeRaport();
void payInvoice();
}
public interface FactoryEmployee{
void doFactoryJob();
}
class Accountant implements OfficeEmployee {
@Override
public void writeRaport() {
}
@Override
public void doFactoryJob() {
throw new UnsupportedOperationException("Not supported yet.");
}
@Override
public void payInvoice() {
}
}
class FactoryWorker implements FactoryEmployee{
@Override
public void doFactoryJob() {
}
}
DIP – dependency inversion principle
High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces). The abstraction should not depend on details. The details should depend on the abstraction. This is one of the most important principles in programming. Many frameworks implement this principle through containers from which we retrieve instances to inject into objects of other classes.
Here is an example of bad code.
class CreditCardProcessor {
public void processPayment(double amount) {
System.out.println("Processing credit card payment of " + amount);
}
}
class PaymentProcessor {
private CreditCardProcessor creditCardProcessor;
public PaymentProcessor() {
this.creditCardProcessor = new CreditCardProcessor();
}
public void makePayment(double amount) {
creditCardProcessor.processPayment(amount);
}
}
public class Main {
public static void main(String[] args) {
PaymentProcessor paymentProcessor = new PaymentProcessor();
paymentProcessor.makePayment(100.0);
}
}
Here, we can see a direct dependency. Any change in the CreditCardProcessor class will affect the PaymentProcessor. That’s why we create the IPayment interface, which allows us to inject objects that implement the IPayment interface (the processPayment method).
interface IPayment{
void processPayment(double amount);
}
class CreditCardProcessor implements IPayment{
@Override
public void processPayment(double amount) {
System.out.println("Processing credit card payment of " + amount);
}
}
class PayPalProcessor implements Ipayment{
@Override
public void processPayment(double amount) {
System.out.println("Processing PayPal payment of " + amount);
}
}
class PaymentService {
private PaymentProcessorInterface paymentProcessor;
public PaymentService(PaymentProcessorInterface paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
public void makePayment(double amount) {
paymentProcessor.processPayment(amount);
}
}
public class Main {
public static void main(String[] args) {
IPayment creditCardProcessor = new CreditCardProcessor();
PaymentService paymentService = new PaymentService(creditCardProcessor);
paymentService.makePayment(100.0);
IPayment payPalProcessor = new PayPalProcessor();
paymentService = new PaymentService(payPalProcessor);
paymentService.makePayment(150.0);
}
}