Understanding SOLID Principles for Better Software Design

Design principles help create good software by ensuring the code is clean and organised. The SOLID principles guide how to structure code into groups, making the system easy to change, understand, and reuse. These principles apply to all kinds of software, not just object-oriented programming. They focus on building parts of the system that are flexible and work well together. SOLID became formalised in the early 2000s and is key to creating strong, maintainable software systems.

Single Responsibility Principle: The Single Responsibility Principle (SRP) means a module should have only one reason to change, not just do one thing. The idea that a function should do one thing is a different rule, not part of SRP.

  • The Single Responsibility Principle (SRP) means a module should have one reason to change.
  • This reason refers to a single "actor" (a group of people needing the same change).
  • A module is usually a source file or a cohesive group of functions and data.
  • Cohesion means the code is focused on serving one actor’s needs.
  • Violating SRP can cause issues with code maintenance and updates.

Problem 1: Duplication

An example of violating the Single Responsibility Principle (SRP) is a?Product Management?class with three methods:?addProduct(),?generateReport(), and?sendNotification().

  • addProduct(): Used by the sales team.
  • generateReport(): Used by the marketing team.
  • sendNotification(): Used by customer support.

By combining these methods in one class, different teams are linked, which can cause issues.

Example Problem:

If a developer modifies a shared function called?validateProduct()?to be stricter for the sales team, it could lead to incorrect reports for the marketing team. This happens because changes for one team unintentionally affect another.

The SRP suggests separating code for different responsibilities to prevent these issues.

Problem 2: Merges

Merges often occur when a source file has many methods serving different teams.

Example:

In a?Customer Management?class with methods for processing orders, handling returns, and collecting feedback, changes from the?Sales Team?and the?Support Team?can conflict if they work on the same file at the same time.

Merges can be risky, as no tool can handle every situation perfectly, potentially affecting multiple teams.

To avoid these issues, it's important to separate code for different teams so they can work independently without conflicts.

Solution:

There are several ways to address this issue, mainly by moving functions into separate classes.

One simple solution is to keep data and functions apart. For example, a?ProductData?class could store product information, while different classes handle adding products, generating reports, and processing returns. These classes would remain independent to avoid accidental duplication.

Downside:?Developers have to manage multiple classes. The?Facade?pattern can help by using a?ProductFacade?class to coordinate these functions with minimal code.

Some developers prefer to keep important logic in the original?Product?class and use it as a facade for the other methods.

While it might seem like each class would have just one function, they can have many methods for various tasks, with several private methods hidden from view.

Open-Closed Principle (OCP)

The Open-Closed Principle says that software should be?open for extension?but?closed for modification. This means you can add new features without changing existing code.

Why is OCP Important?

Following OCP helps keep software stable. If adding a new feature requires changing a lot of old code, it can lead to errors.

Example Scenario

Imagine you have a mobile app that shows products in a grid view. If you want to add a list view for users:

  • Instead of changing the grid view code, create a new class for the list view.
  • This way, the original grid code stays the same and reduces the chance of bugs.

How to Apply OCP

  1. Separate Responsibilities:?Break tasks into different classes. Each class should handle one thing.
  2. Use Interfaces:?Define interfaces for parts of your code that may change. This allows you to add new features easily.
  3. Keep Relationships Unidirectional:?Higher-level components should depend on lower-level ones, but not the other way around. This protects them from changes.

DIRECTIONAL CONTROL

If the previous class design seemed complex, it’s mainly to ensure that the connections between parts point the right way. For example, in an e-commerce app, the?OrderProcessor?should depend on the?PaymentGateway?rather than the other way around. This way, changes in payment methods won’t affect how orders are processed.

INFORMATION HIDING

The?OrderRequester?interface helps keep the?OrderController?from needing to know the details of the?InventoryManager. If this interface wasn’t there, the OrderController would have indirect dependencies on the Inventory components, which isn’t ideal.

By using the?OrderRequester, we protect both the OrderController and the InventoryManager from changes in each other, simplifying maintenance and updates.


LSP: THE LISKOV SUBSTITUTION PRINCIPLE

The Liskov Substitution Principle (LSP), defined by Barbara Liskov in 1988, states that if you have a class S and a class T, you should be able to replace instances of T with instances of S without changing the program's behaviour. If this holds true, S is a subtype of T.

EXAMPLES TO ILLUMINATE LSP

GUIDING INHERITANCE

Let’s consider an e-commerce application with a class named PaymentMethod. There are two subtypes: CreditCard and PayPal. Each type has its own method for processing payments.

  • PaymentMethod (base class)Method: processPayment(amount)
  • CreditCard (subtype)Method: processPayment(amount) uses credit card logic
  • PayPal (subtype)Method: processPayment(amount) uses PayPal logic

In this case, the PaymentProcessor class that uses PaymentMethod can process payments without needing to know if it’s dealing with a CreditCard or PayPal. This follows LSP, as both subtypes can be used interchangeably.

THE SQUARE/RECTANGLE EXAMPLE

A classic example of LSP violation is the square/rectangle problem. Suppose we have a Product class that can calculate discounts based on price and quantity.

If we create a BulkProduct class as a subtype of Product that has special rules for discounts, it might break LSP if the discount calculation expects a standard Product. For instance, if the BulkProduct applies a discount per item instead of a flat rate, it could lead to unexpected results when a program expects standard product behavior.

LSP IN ARCHITECTURE

Originally seen as a guideline for using inheritance, LSP has evolved into a broader principle that applies to interfaces and implementations. In an e-commerce application, various payment methods, shipping methods, or product types can implement similar interfaces, ensuring that they behave consistently.

EXAMPLE OF LSP VIOLATION

Imagine an e-commerce platform where customers can choose delivery methods. Each delivery service has a unique API for processing delivery requests.

For example:

  • FastDelivery API:

fastdelivery.com/api/request?address=24 Maple St&time=3 PM        

  • StandardDelivery API:

standarddelivery.com/api/request?pickup=24 Maple St&delivery_time=3 PM        

If the FastDelivery service changes the query parameters to something different (e.g., using "pickup" instead of "address"), the existing code that constructs requests for these services might break.

To accommodate this, developers might resort to adding if-statements to handle different delivery services, leading to complex and fragile code. Instead, a better design would involve using a configuration file to define API formats for each delivery service:

{
  "FastDelivery": {
    "url": "fastdelivery.com/api/request",
    "params": {
      "address": "%s",
      "time": "%s"
    }
  },
  "StandardDelivery": {
    "url": "standarddelivery.com/api/request",
    "params": {
      "pickup": "%s",
      "delivery_time": "%s"
    }
  }
}        

This approach allows the e-commerce platform to dynamically adapt to different API formats without altering core logic. As a result, all delivery services can be treated uniformly, ensuring adherence to the LSP.


ISP: THE INTERFACE SEGREGATION PRINCIPLE

The Interface Segregation Principle (ISP) is about creating smaller, focused interfaces to avoid unnecessary dependencies.

THE PROBLEM WITH A BIG INTERFACE

Imagine you have a class called OPS that has several operations (op1, op2, op3). Now, let's say three users interact with this class:

  • User1 only uses op1.
  • User2 only uses op2.
  • User3 only uses op3.

If OPS is implemented in a language like Java, User1 will depend on all operations, even the ones it doesn’t use. This means that if op2 or op3 changes, User1 has to be recompiled and redeployed, even though it doesn’t use those operations. This creates unnecessary work.

SOLUTION: SEPARATE INTERFACES

To solve this, we can split the operations into separate interfaces. This way, User1 only depends on U1Ops and op1, so changes to other operations won’t affect it.

ISP AND PROGRAMMING LANGUAGES

The impact of ISP can vary based on the programming language:

  • Statically Typed Languages (like Java): Require explicit declarations, creating dependencies that force recompilation.
  • Dynamically Typed Languages (like Ruby or Python): Do not require such declarations, making them more flexible and less tightly coupled.

ISP IN SOFTWARE ARCHITECTURE

At a broader level, the ISP warns against depending on larger modules than necessary. For example, if a system S includes a framework F that is tied to a specific database D, then S also depends on D through F.

If D has features that F doesn’t use, changes to those features can force F and S to redeploy, even though they don’t rely on those features. This can lead to unnecessary complications and failures in the system.

Understanding the Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) emphasizes that the most flexible systems depend on abstractions rather than concrete implementations. This means your code should refer to interfaces or abstract classes instead of specific classes.

Why It Matters

In languages like Java, using concrete classes directly can lead to tightly coupled code. For example, if a method in one class calls another class’s method, any change to that method could require recompiling and redeploying all dependent code. While it’s impossible to avoid all concrete dependencies—like the String class in Java—it's crucial to minimize dependencies on unstable or frequently changing components.

Favoring Stable Abstractions

  • Stable Interfaces: Changes to an abstract interface will affect its implementations, but changes to implementations often don’t require changes to the interface itself. This makes interfaces more stable.
  • Best Practices for Code:Use Interfaces: Always refer to abstract interfaces rather than concrete classes in your code.Avoid Inheriting from Concrete Classes: Inheritance from concrete classes creates rigid dependencies that can complicate future changes.Don’t Override Concrete Methods: Overriding methods from concrete classes doesn’t eliminate dependencies; instead, make the method abstract and provide multiple implementations.Avoid Naming Concrete Classes: Refrain from directly referencing volatile concrete classes in your code.

Managing Concrete Dependencies

Creating instances of concrete classes can introduce unwanted dependencies. To manage this, use the Abstract Factorypattern. For example:

  1. Application uses a service through an interface called Service.
  2. The ServiceFactory interface handles the creation of service instances without directly depending on the concrete implementation.
  3. ServiceFactoryImpl implements the factory, creating and returning instances of the concrete service.

In this way, all dependencies flow toward the abstract components, while control flows in the opposite direction. This inversion of dependencies is the essence of DIP.

Concrete Components and Main Functions

While it’s ideal to minimize direct dependencies on concrete components, every system will likely have at least one, often called main, which contains the main function. This component may instantiate the factory and expose it for use throughout the application, keeping the overall architecture cleaner and more maintainable.

Simple Example of Dependency Inversion Principle (DIP)

Imagine you are building an app that sends notifications, and you have two types of notifications: Email and SMS.

Without DIP (Bad Approach):

Here, the NotificationService directly depends on the EmailSender and SmsSender classes, creating a tight coupling.

class NotificationService {
    private EmailSender emailSender = new EmailSender();
    private SmsSender smsSender = new SmsSender();

    public void send(String message) {
        emailSender.sendEmail(message);
        smsSender.sendSms(message);
    }
}        

With DIP (Good Approach):

We introduce an abstraction (NotificationSender) and let the NotificationService depend on this interface, allowing for easy extension in the future (e.g., adding Push Notifications).

interface NotificationSender {
    void send(String message);
}

class EmailSender implements NotificationSender {
    public void send(String message) {
        // Send email
    }
}

class SmsSender implements NotificationSender {
    public void send(String message) {
        // Send SMS
    }
}

class NotificationService {
    private NotificationSender sender;

    public NotificationService(NotificationSender sender) {
        this.sender = sender;
    }

    public void send(String message) {
        sender.send(message);
    }
}        

Now, NotificationService depends on the NotificationSender interface, not the concrete EmailSender or SmsSender. This makes it easy to add new types of notifications without changing the core logic.


Reference: Clean Architecture by Robert C. Martin (ISBN-13: 978-0-13-449416-6 | ISBN-10: 0-13-449416-4)

要查看或添加评论,请登录

Saurabh Kumar的更多文章

社区洞察

其他会员也浏览了