Skip to content

SOLID Principles

Updated: at 06:22 PM (11 min read)

image

SOLID stands For

TLDR

SRP - 1 thing in 1 class OCP - Code to Interfaces, not to concrete LSP - Subclass should be replaced blindly without breaking functionality ISP - No big interfaces, better to create separate simple interface then 1 bulky interface DIP - High modules should be dependent on low level implementation via interfaces

Single Responsibility Principle (SRP)

It just means that A class is supposed to have one responsibilty (core responsibility), not more not less. This make things simpler and easy to maintain.

Backend Engineering

In backend development, imagine you’re building a system that handles user accounts. According to SRP, you should have separate classes or modules for different responsibilities. For example:

Each class has a single responsibility: UserManager deals with user accounts, EmailService handles email, and Logger takes care of logging. This makes the system easier to maintain and update because changes in one area won’t affect the others.

Android Development

In Android development, think about a screen in your app that shows user profiles. You might have:

Each component has a specific responsibility: UserProfileActivity manages the UI, UserProfileViewModel handles the data, and UserProfileAdapter deals with displaying the data. If you need to change how data is displayed or handled, you only need to update the relevant part without affecting the other components.

Non-Engineering Example

Let’s use a school scenario:

In this setup, each role has a single, clear responsibility. A teacher doesn’t handle library books or make school policies, which makes each role more effective at its specific job.

image

Open Close Principle (OCP)

It advocate for coding to interfaces rather than concrete implementations. This approach promotes flexibility and extensibility in your codebase.

Backend Development

Imagine you’re building an online shopping system with different types of discounts:

Initially, it might only handle a fixed discount. But, according to OCP, you should design it so that you can easily add new types of discounts without modifying the existing DiscountService class.

Here’s how you could implement it:

When you need to add a new discount type, you simply create a new class that implements DiscountStrategy without changing DiscountService. This keeps your codebase stable and allows you to extend functionality easily.

Android Development

Suppose you’re developing an Android app that tracks user interactions through various analytics services. You have a system that logs events to different analytics platforms:

AnalyticsManager: Handles the logging of events to different analytics services. Initially, it might only support logging to Mixpanel. To follow OCP:

By using this design, AnalyticsManager can work with any analytics service that implements the AnalyticsLogger interface, and you can easily extend it to support new services without modifying the existing code.

Non-Engineering Example

Let’s use a restaurant scenario:

Initially, the restaurant offers burgers and pizzas. To follow OCP:

When you want to add a new dish, like pasta, you create a new class Pasta that extends MenuItem without changing the existing Menu or MenuItem classes. This allows you to expand the menu without altering existing code.

image

Liskov Substitution Principle (LSP)

States that objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program.

Backend Example

Imagine you’re developing a backend system for a payment processing application. You have a base class for different types of payment methods:

You have different subclasses for specific payment methods:

According to LSP:

class PaymentMethod {
    public void processPayment(double amount) {
        // General payment processing logic
    }
}

class CreditCardPayment extends PaymentMethod {
    @Override
    public void processPayment(double amount) {
        // Credit card-specific payment processing logic
    }
}

class PayPalPayment extends PaymentMethod {
    @Override
    public void processPayment(double amount) {
        // PayPal-specific payment processing logic
    }
}

public void handlePayment(PaymentMethod paymentMethod, double amount) {
    paymentMethod.processPayment(amount);
}

You can use either CreditCardPayment or PayPalPayment with handlePayment without causing errors or unexpected behavior.

Android Example

Imagine you’re working on an Android app that handles different types of user notifications. You have a base class for notifications:

Subclasses handle specific notification types:

According to LSP:

open class Notification {
    open fun sendNotification() {
        // General notification sending logic
    }
}

class EmailNotification : Notification() {
    override fun sendNotification() {
        // Email-specific notification sending logic
    }
}

class PushNotification : Notification() {
    override fun sendNotification() {
        // Push-specific notification sending logic
    }
}

fun notifyUser(notification: Notification) {
    notification.sendNotification()
}

You can pass either EmailNotification or PushNotification to notifyUser without any issues, and both will correctly follow the contract of Notification.

Non-Engineering Example

Consider a scenario in a library system:

Subclasses represent specific types of library items:

According to LSP:

class LibraryItem {
    public void checkout() {
        // General checkout logic
    }
}

class Book extends LibraryItem {
    @Override
    public void checkout() {
        // Book-specific checkout logic
    }
}

class DVD extends LibraryItem {
    @Override
    public void checkout() {
        // DVD-specific checkout logic
    }
}

public void processCheckout(LibraryItem item) {
    item.checkout();
}

You can process both Book and DVD with processCheckout without causing any issues, as both adhere to the expected behavior of LibraryItem.

image

Difference between LSP and OCP

The Liskov Substitution Principle (LSP) and the Open/Closed Principle (OCP) are both SOLID principles, but they address different aspects of software design:

Liskov Substitution Principle (LSP)

Example: If you have a Bird class with a fly() method, a Penguin subclass should not break the system if it is used where a Bird is expected, even if penguins don’t fly.

Open/Closed Principle (OCP)

Example: If you have an AnalyticsManager that logs events, you should be able to add support for a new analytics service by creating a new class implementing the AnalyticsLogger interface, without modifying the existing AnalyticsManager code.

Key Differences

In summary, LSP is about ensuring that subclasses can be used interchangeably with their superclasses without breaking the system, while OCP is about designing systems that can be extended without changing existing code.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use. This means that an interface should be specific to a particular client’s needs rather than having a large, general-purpose interface that forces implementations to provide methods they don’t need.

Backend Example

Suppose you’re developing an application with various types of user notifications. You start with a single interface for notifications:

Initially, this works, but as you add more types of notifications, the interface becomes bloated, and not all implementations need all methods.

To follow ISP:

Each service only implements the methods it actually needs, avoiding unnecessary dependencies.

interface EmailNotificationService {
    void sendEmail(String message);
}

interface SMSNotificationService {
    void sendSMS(String message);
}

interface PushNotificationService {
    void sendPushNotification(String message);
}

class EmailNotification implements EmailNotificationService {
    @Override
    public void sendEmail(String message) {
        // Email-specific implementation
    }
}

class SMSNotification implements SMSNotificationService {
    @Override
    public void sendSMS(String message) {
        // SMS-specific implementation
    }
}

Android Example

In an Android app, you might have an interface for various media players:

For a more specialized approach following ISP:

Each player class implements only the methods it requires.

interface AudioPlayer {
    fun playAudio()
    fun pause()
}

interface VideoPlayer {
    fun playVideo()
    fun pause()
    fun stop()
}

class SimpleAudioPlayer : AudioPlayer {
    override fun playAudio() {
        // Audio playback implementation
    }

    override fun pause() {
        // Pause audio playback
    }
}

class AdvancedVideoPlayer : VideoPlayer {
    override fun playVideo() {
        // Video playback implementation
    }

    override fun pause() {
        // Pause video playback
    }

    override fun stop() {
        // Stop video playback
    }
}

Non-Engineering Example

Consider a library management system where you have a single interface for library items:

You find that books, magazines, and DVDs have different needs:

To follow ISP:

Each item class implements only the interfaces relevant to its functionality.

interface CheckOutable {
    void checkOut();
    void returnItem();
}

interface Reservable {
    void reserve();
}

interface Playable {
    void play();
}

class Book implements CheckOutable {
    @Override
    public void checkOut() {
        // Book checkout logic
    }

    @Override
    public void returnItem() {
        // Book return logic
    }
}

class Magazine implements Reservable {
    @Override
    public void reserve() {
        // Magazine reservation logic
    }
}

class DVD implements CheckOutable, Playable {
    @Override
    public void checkOut() {
        // DVD checkout logic
    }

    @Override
    public void returnItem() {
        // DVD return logic
    }

    @Override
    public void play() {
        // DVD play logic
    }
}

The Interface Segregation Principle encourages the design of small, specific interfaces rather than large, general-purpose ones. This helps in creating more maintainable and flexible systems where classes or components are not forced to implement methods they don’t need.

Dependency Inversion Principle (DIP)

it states that:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces).
  2. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

In simpler terms, DIP is about ensuring that high-level business logic is decoupled from low-level implementation details by depending on abstractions rather than concrete classes. This helps to reduce dependencies and increase flexibility in your code.

Backend Example

Imagine you’re building a logging system where your application needs to log messages to various destinations:

  1. LogService: This is a high-level module that needs to log messages.
  2. ConsoleLogger and FileLogger: These are low-level modules that implement the actual logging functionality.

Without DIP:

class LogService {
    private ConsoleLogger consoleLogger = new ConsoleLogger();

    public void log(String message) {
        consoleLogger.logToConsole(message);
    }
}

class ConsoleLogger {
    public void logToConsole(String message) {
        System.out.println(message);
    }
}

With DIP: Define an abstraction (interface):

interface Logger {
    void log(String message);
}

Implement the abstraction:

class ConsoleLogger implements Logger {
    @Override
    public void log(String message) {
        System.out.println(message);
    }
}

class FileLogger implements Logger {
    @Override
    public void log(String message) {
        // Code to log message to a file
    }
}

Depend on the abstraction:

class LogService {
    private Logger logger;

    public LogService(Logger logger) {
        this.logger = logger;
    }

    public void log(String message) {
        logger.log(message);
    }
}

Usage:

Logger consoleLogger = new ConsoleLogger();
LogService logService = new LogService(consoleLogger);

logService.log("Hello, world!");

Android Example

Consider an Android app where you need to handle user authentication with different methods:

  1. AuthenticationManager: A high-level module that manages authentication.
  2. FirebaseAuth and OAuth2Auth: Low-level modules that implement specific authentication methods.

Non-Engineering Example

Imagine a business that handles various types of reports:

  1. ReportGenerator: A high-level module that generates reports.
  2. PDFReportGenerator and ExcelReportGenerator: Low-level modules that generate specific types of reports.