Week 135 — What is SOLID in the context of OOP?

Question of the Week #135
What is SOLID in the context of OOP?
5 Replies
Eric McIntyre
Eric McIntyre3mo ago
SOLID is an acronym used for 5 principles of clean code in object-oriented programming: The Single Responsibility Principle typically refers to the fact that a class should have one purpose/be responsible for one thing (part of the program) or have only a single "reason" to change. If a class is doing responsible for various things not directly related to each other, it should likely be split up into multiple classes. Sometimes, it is considered to be about each class having a single developer or group of developers being responsible for it but that definition is less common. For example, a class managing users in a system should not be responsible for managing an inventory. This also means that general utility classes doing unrelated actions should be avoided if reasonably possible. The Open/Closed principle means that classes should be open for extension but closed for modification. This means that classes should be written in a way that new functionality can be added without (significantly) modifying existing code. Classes should (where reasonable) permit subclasses to extend their functionality by adding additional fields, creating new methods and overriding existing methods of the superclass. The Liskov substitution principle states that it should be possible to substitute superclasses (or interfaces) by superclasses while keeping the specification/promises of the superclass. If the superclass guarantees functionality, subclasses should keep that to make sure all code relying on these operations still works the same if a subclass is used. While this is not always possible to achieve (sometimes it is necessary to restrict or change some functionality in order to enable other functionality), this principle should be followed when it is reasonably possible. If this principle is not followed, the subclass should be passed solely to code that is known to not rely on the compromised functionality. For example, if a class representing users promises that all users allow retrieving their roles, subclasses should adhere to that:
public class User{
private final String username;
private List<Role> roles;
public User(String username, List<Role> roles) {
this.username = username;
this.roles = new ArrayList(roles);
}

public String getUsername(){
return username;
}
/**
* All users allow retrieving their roles as a List.
*/
public List<Role> getRoles() {
return List.copyOf(roles);
}
}
public class User{
private final String username;
private List<Role> roles;
public User(String username, List<Role> roles) {
this.username = username;
this.roles = new ArrayList(roles);
}

public String getUsername(){
return username;
}
/**
* All users allow retrieving their roles as a List.
*/
public List<Role> getRoles() {
return List.copyOf(roles);
}
}
public class NoncompilantUser extends User {
@Override
public List<Role> getRoles() {
throw new UnsupportedOperationException();//Don't do that - this violates the specification/promises of User
}
}
public class NoncompilantUser extends User {
@Override
public List<Role> getRoles() {
throw new UnsupportedOperationException();//Don't do that - this violates the specification/promises of User
}
}
Interface Segregation means that an interface should only mandate the operations necessary to use it. Classes implementing an interfaces should not be required to implement methods that are not needed for their functionality. Interfaces should be modeled after what they actually represent, not by their implementation.
public interface Product {
int getPriceInCents();//assume products in this shop generally have a price

int getScreenResolution();//Don't do that - there could be products that don't have a screen
}
public class Computer implements Product{
private int priceInCents;
private int screenResolution;
public Computer(int price, int screenResolution) {
this.priceInCents = price;
this.screenResolution = screenResolution;
}
@Override
public int getPriceInCents(){
return priceInCents;
}
@Override
public int getScreenResolution(){
return screenResolution;
}
}
public class Battery implements Product{
private int priceInCents;
public Battery(int price) {
this.priceInCents = price;
}
@Override
public int getPriceInCents(){
return priceInCents;
}
@Override
public int getScreenResolution(){
// Here's the problem - batteries shouldn't implement anything regarding screen resolutions
throw new UnsupportedOperationException();
}
}
public interface Product {
int getPriceInCents();//assume products in this shop generally have a price

int getScreenResolution();//Don't do that - there could be products that don't have a screen
}
public class Computer implements Product{
private int priceInCents;
private int screenResolution;
public Computer(int price, int screenResolution) {
this.priceInCents = price;
this.screenResolution = screenResolution;
}
@Override
public int getPriceInCents(){
return priceInCents;
}
@Override
public int getScreenResolution(){
return screenResolution;
}
}
public class Battery implements Product{
private int priceInCents;
public Battery(int price) {
this.priceInCents = price;
}
@Override
public int getPriceInCents(){
return priceInCents;
}
@Override
public int getScreenResolution(){
// Here's the problem - batteries shouldn't implement anything regarding screen resolutions
throw new UnsupportedOperationException();
}
}
Instead Product should have been split into distinct interfaces for products (having a price) and e.g. products with a screen. Dependency Inversion (sometimes Dependency Injection) means that classes should not be responsible for obtaining the components (e.g. objects) they need. Instead, these components should be supplied by the part instantiating objects of the class. While this can be a dependency injection framework, this is not required for the dependency inversion principle.
public class NoncompilantUserManagement {
//Don't do that
private UserRepository repository = UserRepository.createRepository(Database.connectToDatabase());

public User createUser(String username) throws UserAlreadyExistsException {
if(repository.hasUserWithUsername(username)) {
throw new UserAlreadyExistsException(username);
}
User user = new User(username, List.of());
repository.insertUser(user);
return user;
}
}
public class NoncompilantUserManagement {
//Don't do that
private UserRepository repository = UserRepository.createRepository(Database.connectToDatabase());

public User createUser(String username) throws UserAlreadyExistsException {
if(repository.hasUserWithUsername(username)) {
throw new UserAlreadyExistsException(username);
}
User user = new User(username, List.of());
repository.insertUser(user);
return user;
}
}
public class CompilantUserManagement {
private UserRepository repository;

public CompilantUserManagement(UserRepository repository) {
this.repository = repository;
}

public User createUser(String username) throws UserAlreadyExistsException {
if(repository.hasUserWithUsername(username)) {
throw new UserAlreadyExistsException(username);
}
User user = new User(username, List.of());
repository.insertUser(user);
return user;
}
}
public class CompilantUserManagement {
private UserRepository repository;

public CompilantUserManagement(UserRepository repository) {
this.repository = repository;
}

public User createUser(String username) throws UserAlreadyExistsException {
if(repository.hasUserWithUsername(username)) {
throw new UserAlreadyExistsException(username);
}
User user = new User(username, List.of());
repository.insertUser(user);
return user;
}
}
Eric McIntyre
Eric McIntyre3mo ago
These principles should be used with care and not just blindly followed without considering context. The goal of these principles is to help developers write clean, maintainable code but that doesn't mean that following these principles will always result in clean code.
📖 Sample answer from dan1st
Eric McIntyre
Eric McIntyre3mo ago
S - Single Responsibility Principle O - Open-Closed Principle L - Liskov Substitution Principle I - Interface Segregation Principle D - Dependency Inversion Principle
Submission from fpznx
Eric McIntyre
Eric McIntyre3mo ago
SOLID is an acronym that presents five fundamental design principles in any object-oriented programming. It was introduced by Robert C. Martin. S - Single Responsibility Principle (SRP) "A class should have only one reason to change." This principle states that a class should have only one job or responsibility. When a class has multiple responsibilities, changes to one responsibility can affect the others, making the code fragile. Bad Example:
public class Employee {
private String name;
private double salary;

// Employee data management
public void setName(String name) { this.name = name; }
public String getName() { return name; }

// Different responsibility!
public double calculatePay() {
return salary * 1.1;
}

// Different responsibility!
public void printEmployeeReport() {
System.out.println("Employee: " + name + ", Salary: " + salary);
}
}
public class Employee {
private String name;
private double salary;

// Employee data management
public void setName(String name) { this.name = name; }
public String getName() { return name; }

// Different responsibility!
public double calculatePay() {
return salary * 1.1;
}

// Different responsibility!
public void printEmployeeReport() {
System.out.println("Employee: " + name + ", Salary: " + salary);
}
}
Proper Example:
public class Employee {
private String name;
private double salary;

public void setName(String name) { this.name = name; }
public String getName() { return name; }
public double getSalary() { return salary; }
}

public class PayrollCalculator {
public double calculatePay(Employee employee) {
return employee.getSalary() * 1.1;
}
}

public class EmployeeReporter {
public void printEmployeeReport(Employee employee) {
System.out.println("Employee: " + employee.getName() +
", Salary: " + employee.getSalary());
}
}
public class Employee {
private String name;
private double salary;

public void setName(String name) { this.name = name; }
public String getName() { return name; }
public double getSalary() { return salary; }
}

public class PayrollCalculator {
public double calculatePay(Employee employee) {
return employee.getSalary() * 1.1;
}
}

public class EmployeeReporter {
public void printEmployeeReport(Employee employee) {
System.out.println("Employee: " + employee.getName() +
", Salary: " + employee.getSalary());
}
}
O - Open/Closed Principle (OCP) "Software entities should be open for extension but closed for modification." You should be able to extend a class's behavior without modifying its existing code, typically obtained through inheritance and polymorphism. Bad Example:
public class AreaCalculator {
public double calculateArea(Object shape) {
if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
return rectangle.width * rectangle.height;
} else if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * circle.radius * circle.radius;
}
// Adding new shapes requires modifying this method!
return 0;
}
}
public class AreaCalculator {
public double calculateArea(Object shape) {
if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
return rectangle.width * rectangle.height;
} else if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * circle.radius * circle.radius;
}
// Adding new shapes requires modifying this method!
return 0;
}
}
Good Example:
public abstract class Shape {
public abstract double calculateArea();
}

public class Rectangle extends Shape {
private double width, height;

public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}

@Override
public double calculateArea() {
return width * height;
}
}

public class Circle extends Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}

public class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea(); // No modification needed for new shapes!
}
}
public abstract class Shape {
public abstract double calculateArea();
}

public class Rectangle extends Shape {
private double width, height;

public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}

@Override
public double calculateArea() {
return width * height;
}
}

public class Circle extends Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}

public class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea(); // No modification needed for new shapes!
}
}
L - Liskov Substitution Principle (LSP) "Objects of a superclass should be replaceable with objects of a subclass without breaking the application." Derived classes must be substitutable for their base classes without altering the way the program is supposed to function.
public class Bird {
public void fly() {
System.out.println("Flying...");
}
}

public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly!");
}
}

// This violates LSP because you can't substitute Penguin for Bird
public class BirdWatcher {
public void watchBird(Bird bird) {
bird.fly(); // This will throw exception for Penguin!
}
}
public class Bird {
public void fly() {
System.out.println("Flying...");
}
}

public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly!");
}
}

// This violates LSP because you can't substitute Penguin for Bird
public class BirdWatcher {
public void watchBird(Bird bird) {
bird.fly(); // This will throw exception for Penguin!
}
}
Good Example:
public abstract class Bird {
public abstract void move();
}

public class FlyingBird extends Bird {
@Override
public void move() {
fly();
}

private void fly() {
System.out.println("Flying...");
}
}

public class Penguin extends Bird {
@Override
public void move() {
swim();
}

private void swim() {
System.out.println("Swimming...");
}
}

public class BirdWatcher {
public void watchBird(Bird bird) {
bird.move(); // Works for all Bird subtypes
}
}
public abstract class Bird {
public abstract void move();
}

public class FlyingBird extends Bird {
@Override
public void move() {
fly();
}

private void fly() {
System.out.println("Flying...");
}
}

public class Penguin extends Bird {
@Override
public void move() {
swim();
}

private void swim() {
System.out.println("Swimming...");
}
}

public class BirdWatcher {
public void watchBird(Bird bird) {
bird.move(); // Works for all Bird subtypes
}
}
I - Interface Segregation Principle (ISP) "No client should be forced to depend on methods it does not use." Instead of one large interface, create multiple smaller, specific interfaces so that clients only need to know about methods that are relevant to them. Bad Example:
public interface Worker {
void work();
void eat();
void sleep();
}

public class Human implements Worker {
@Override
public void work() { System.out.println("Working..."); }

@Override
public void eat() { System.out.println("Eating..."); }

@Override
public void sleep() { System.out.println("Sleeping..."); }
}

public class Robot implements Worker {
@Override
public void work() { System.out.println("Working..."); }

@Override
public void eat() {
throw new UnsupportedOperationException("Robots don't eat!");
}

@Override
public void sleep() {
throw new UnsupportedOperationException("Robots don't sleep!");
}
}
public interface Worker {
void work();
void eat();
void sleep();
}

public class Human implements Worker {
@Override
public void work() { System.out.println("Working..."); }

@Override
public void eat() { System.out.println("Eating..."); }

@Override
public void sleep() { System.out.println("Sleeping..."); }
}

public class Robot implements Worker {
@Override
public void work() { System.out.println("Working..."); }

@Override
public void eat() {
throw new UnsupportedOperationException("Robots don't eat!");
}

@Override
public void sleep() {
throw new UnsupportedOperationException("Robots don't sleep!");
}
}
Good Example:
public interface Workable {
void work();
}

public interface Eatable {
void eat();
}

public interface Sleepable {
void sleep();
}

public class Human implements Workable, Eatable, Sleepable {
@Override
public void work() { System.out.println("Working..."); }

@Override
public void eat() { System.out.println("Eating..."); }

@Override
public void sleep() { System.out.println("Sleeping..."); }
}

public class Robot implements Workable {
@Override
public void work() { System.out.println("Working..."); }
}
public interface Workable {
void work();
}

public interface Eatable {
void eat();
}

public interface Sleepable {
void sleep();
}

public class Human implements Workable, Eatable, Sleepable {
@Override
public void work() { System.out.println("Working..."); }

@Override
public void eat() { System.out.println("Eating..."); }

@Override
public void sleep() { System.out.println("Sleeping..."); }
}

public class Robot implements Workable {
@Override
public void work() { System.out.println("Working..."); }
}
D - Dependency Inversion Principle (DIP) "High-level modules should not depend on low-level modules. Both should depend on abstractions." Depend on abstractions (interfaces) rather than a concrete implementation. Bad Example:
public class MySQLDatabase {
public void save(String data) {
System.out.println("Saving to MySQL: " + data);
}
}

public class UserService {
private MySQLDatabase database = new MySQLDatabase(); // Tight coupling

public void saveUser(String userData) {
database.save(userData);
}
}
public class MySQLDatabase {
public void save(String data) {
System.out.println("Saving to MySQL: " + data);
}
}

public class UserService {
private MySQLDatabase database = new MySQLDatabase(); // Tight coupling

public void saveUser(String userData) {
database.save(userData);
}
}
Good Example:
public interface Database {
void save(String data);
}

public class MySQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("Saving to MySQL: " + data);
}
}

public class PostgreSQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("Saving to PostgreSQL: " + data);
}
}

public class UserService {
private Database database; // Depends on abstraction

public UserService(Database database) {
this.database = database;
}

public void saveUser(String userData) {
database.save(userData);
}
}

// Usage
public class Main {
public static void main(String[] args) {
Database db = new MySQLDatabase(); // Easy to switch implementations
UserService service = new UserService(db);
service.saveUser("John Doe");
}
}
public interface Database {
void save(String data);
}

public class MySQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("Saving to MySQL: " + data);
}
}

public class PostgreSQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("Saving to PostgreSQL: " + data);
}
}

public class UserService {
private Database database; // Depends on abstraction

public UserService(Database database) {
this.database = database;
}

public void saveUser(String userData) {
database.save(userData);
}
}

// Usage
public class Main {
public static void main(String[] args) {
Database db = new MySQLDatabase(); // Easy to switch implementations
UserService service = new UserService(db);
service.saveUser("John Doe");
}
}
Benefits of Following SOLID Principles: - Maintainability: Code is easier to understand and modify - Flexibility: Easy to extend functionality without breaking existing code - Testability: Classes with single responsibilities are easier to unit test - Reusability: Well-designed components can be reused in different contexts - Reduced Coupling: Components are less dependent on each other - Better Organization: Code structure becomes more logical and predictable Drawbacks of Following SOLID Principles: i) Over-Engineering and Unnecessary Complexity: One of the most common criticisms of SOLID is that it can lead to over-engineering simple solutions. Sometimes developers may create elaborate class hierarchies and interfaces when a simple, straightforward approach would suffice.
// Overkill
public interface Calculator {
double calculate(double a, double b);
}

public interface CalculatorFactory {
Calculator createCalculator(String operation);
}

public class AdditionCalculator implements Calculator {
@Override
public double calculate(double a, double b) {
return a + b;
}
}

public class CalculatorFactoryImpl implements CalculatorFactory {
@Override
public Calculator createCalculator(String operation) {
if ("add".equals(operation)) {
return new AdditionCalculator();
}
return null;
}
}

// This would have been perfectly fine:
public class SimpleCalculator {
public double add(double a, double b) {
return a + b;
}
}
// Overkill
public interface Calculator {
double calculate(double a, double b);
}

public interface CalculatorFactory {
Calculator createCalculator(String operation);
}

public class AdditionCalculator implements Calculator {
@Override
public double calculate(double a, double b) {
return a + b;
}
}

public class CalculatorFactoryImpl implements CalculatorFactory {
@Override
public Calculator createCalculator(String operation) {
if ("add".equals(operation)) {
return new AdditionCalculator();
}
return null;
}
}

// This would have been perfectly fine:
public class SimpleCalculator {
public double add(double a, double b) {
return a + b;
}
}
ii) Performance Overhead SOLID principles often introduce additional layers of abstraction, which can impact performance: - Interface calls: Virtual method calls through interfaces can be slower than direct method calls. - Object creation: More objects and abstractions mean more memory allocation and garbage collection. - Reflection overhead: Dependency injection frameworks often use reflection, which has performance costs in runtime.
Eric McIntyre
Eric McIntyre3mo ago
There are a ton of disadvantages/drawbacks that can be discussed about SOLID principle, however the key is know when to apply SOLID Principles and finding balance. You should ask yourself these questions: - Is this code likely to change frequently? - Do I need to support multiple implementations? - Is the complexity justified by the flexibility gained? - Will other developers need to extend this code? Practical Guidelines: - Start simple and refactor toward SOLID when complexity justifies it - Consider the YAGNI principle (You Aren't Gonna Need It) - Balance flexibility with simplicity - Don't create abstractions until you have at least 2-3 concrete implementations
⭐ Submission from daysling

Did you find this page helpful?