DEV Community

Cover image for Top 5 Software Design Principles for Building Robust Applications
Amr Saafan for Nile Bits

Posted on • Originally published at nilebits.com

Top 5 Software Design Principles for Building Robust Applications

Building robust applications requires a strong foundation of solid design principles. These principles guide developers in writing code that is not only functional but also maintainable, scalable, and adaptable to change. In this comprehensive guide, we will explore five fundamental software design principles that are essential for creating robust applications: SOLID principles, DRY (Don't Repeat Yourself), KISS (Keep It Simple, Stupid), YAGNI (You Aren't Gonna Need It), and the Principle of Least Astonishment. We'll provide detailed explanations, examples, and code snippets to illustrate how each principle can be applied effectively.

  1. SOLID Principles For Software Design

The SOLID principles are a set of five design principles intended to make software designs more understandable, flexible, and maintainable. The acronym SOLID stands for:

Single Responsibility Principle (SRP)

Open/Closed Principle (OCP)

Liskov Substitution Principle (LSP)

Interface Segregation Principle (ISP)

Dependency Inversion Principle (DIP)

1.1 Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have only one reason to change, meaning it should have only one job or responsibility.

Example

Consider a class that handles both user authentication and logging:

class UserManager:
    def authenticate_user(self, user, password):
        # authentication logic
        pass

    def log_authentication_attempt(self, user):
        # logging logic
        pass
Enter fullscreen mode Exit fullscreen mode

In this example, the UserManager class has two responsibilities: authenticating users and logging authentication attempts. To adhere to SRP, we should separate these responsibilities into different classes:

class Authenticator:
    def authenticate_user(self, user, password):
        # authentication logic
        pass

class Logger:
    def log_authentication_attempt(self, user):
        # logging logic
        pass
Enter fullscreen mode Exit fullscreen mode

By splitting the responsibilities into Authenticator and Logger, we ensure each class has a single responsibility, making the code easier to maintain and extend.

1.2 Open/Closed Principle (OCP)

The Open/Closed Principle states that software entities should be open for extension but closed for modification. This means you should be able to add new functionality to a class without changing its existing code.

Example

Consider a payment processing system:

class PaymentProcessor:
    def process_payment(self, payment_type):
        if payment_type == 'credit':
            self.process_credit_payment()
        elif payment_type == 'paypal':
            self.process_paypal_payment()

    def process_credit_payment(self):
        # credit payment logic
        pass

    def process_paypal_payment(self):
        # PayPal payment logic
        pass
Enter fullscreen mode Exit fullscreen mode

This design violates OCP because adding a new payment type requires modifying the PaymentProcessor class. A better approach is to use inheritance and polymorphism:

class PaymentProcessor:
    def process_payment(self):
        pass

class CreditPaymentProcessor(PaymentProcessor):
    def process_payment(self):
        # credit payment logic
        pass

class PayPalPaymentProcessor(PaymentProcessor):
    def process_payment(self):
        # PayPal payment logic
        pass
Enter fullscreen mode Exit fullscreen mode

With this design, adding a new payment type only requires creating a new subclass, adhering to the Open/Closed Principle.

1.3 Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.

Example

Consider a base class Bird and a derived class Penguin:

class Bird:
    def fly(self):
        print("Flying")

class Penguin(Bird):
    def fly(self):
        raise Exception("Penguins can't fly")
Enter fullscreen mode Exit fullscreen mode

Here, Penguin violates the LSP because it changes the behavior of the fly method. A better design is to separate flying birds from non-flying birds:

class Bird:
    pass

class FlyingBird(Bird):
    def fly(self):
        print("Flying")

class Penguin(Bird):
    pass
Enter fullscreen mode Exit fullscreen mode

With this design, Penguin no longer violates the LSP, as it does not have a fly method.

1.4 Interface Segregation Principle (ISP)

The Interface Segregation Principle states that no client should be forced to depend on interfaces it does not use.

Example

Consider an interface with multiple methods:

class WorkerInterface:
    def work(self):
        pass

    def eat(self):
        pass
Enter fullscreen mode Exit fullscreen mode

A class implementing this interface would need to implement both methods, even if it only requires one. Instead, we can split the interface into smaller, more specific interfaces:

class Workable:
    def work(self):
        pass

class Eatable:
    def eat(self):
        pass
Enter fullscreen mode Exit fullscreen mode

Now, a class can implement only the interface(s) it needs, adhering to the Interface Segregation Principle.

1.5 Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules but should depend on abstractions.

Example

Consider a high-level class that depends on a low-level class:

class LightBulb:
    def turn_on(self):
        print("LightBulb: On")

    def turn_off(self):
        print("LightBulb: Off")

class Switch:
    def __init__(self, light_bulb):
        self.light_bulb = light_bulb

    def operate(self):
        self.light_bulb.turn_on()
Enter fullscreen mode Exit fullscreen mode

This design violates DIP because the Switch class depends directly on the LightBulb class. Instead, we should depend on an abstraction:

class Switchable:
    def turn_on(self):
        pass

    def turn_off(self):
        pass

class LightBulb(Switchable):
    def turn_on(self):
        print("LightBulb: On")

    def turn_off(self):
        print("LightBulb: Off")

class Switch:
    def __init__(self, device):
        self.device = device

    def operate(self):
        self.device.turn_on()
Enter fullscreen mode Exit fullscreen mode

With this design, Switch depends on the Switchable abstraction, adhering to the Dependency Inversion Principle.

  1. DRY (Don't Repeat Yourself) for Software Design

The DRY principle emphasizes the importance of reducing repetition within code. This principle helps minimize redundancy and fosters a codebase that is easier to maintain and extend.

Example

Consider the following code with redundant logic:

def calculate_discounted_price(price, discount):
    return price - (price * discount)

def calculate_final_price(price, discount, tax):
    discounted_price = price - (price * discount)
    return discounted_price + (discounted_price * tax)
Enter fullscreen mode Exit fullscreen mode

The discount calculation logic is repeated in both functions. To adhere to the DRY principle, we should extract this logic into a single function:

def calculate_discount(price, discount):
    return price - (price * discount)

def calculate_discounted_price(price, discount):
    return calculate_discount(price, discount)

def calculate_final_price(price, discount, tax):
    discounted_price = calculate_discount(price, discount)
    return discounted_price + (discounted_price * tax)
Enter fullscreen mode Exit fullscreen mode

This design eliminates redundancy by reusing the calculate_discount function, making the code easier to maintain.

  1. KISS (Keep It Simple, Stupid) for Software Design

The KISS principle states that systems work best when they are kept simple rather than made complex. Simplicity should be a key goal in design, and unnecessary complexity should be avoided.

Example

Consider an overly complex method for calculating the factorial of a number:

def factorial(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result
Enter fullscreen mode Exit fullscreen mode

A simpler approach using recursion is more intuitive and concise:

def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n - 1)
Enter fullscreen mode Exit fullscreen mode

The recursive solution adheres to the KISS principle by providing a simple and straightforward implementation.

  1. YAGNI (You Aren't Gonna Need It) for Software Design

The YAGNI principle advises against adding functionality until it is necessary. This principle helps prevent feature bloat and reduces the complexity of the codebase.

Example

Consider a class with unused methods:

class DataExporter:
    def export_to_csv(self):
        # CSV export logic
        pass

    def export_to_json(self):
        # JSON export logic
        pass

    def export_to_xml(self):
        # XML export logic
        pass
Enter fullscreen mode Exit fullscreen mode

If the application only requires exporting to CSV, the other methods are unnecessary. To adhere to YAGNI, we should remove unused functionality:

class DataExporter:
    def export_to_csv(self):
        # CSV export logic
        pass
Enter fullscreen mode Exit fullscreen mode

This design simplifies the class and reduces maintenance by only including the necessary functionality.

  1. Principle of Least Astonishment for Software Design

The Principle of Least Astonishment states that code should behave in a way that least surprises the users. This principle encourages intuitive and predictable behavior, making software easier to use and understand.

Example

Consider a function that behaves unexpectedly:

def divide(a, b):
    return a / b

print(divide(10, 0))  # Raises an exception
Enter fullscreen mode Exit fullscreen mode

A better approach is to handle the potential exception:

def divide(a, b):
    if b == 0:
        return 'Cannot divide by zero'
    return a / b

print

(divide(10, 0))  # Outputs: Cannot divide by zero
Enter fullscreen mode Exit fullscreen mode

This design adheres to the Principle of Least Astonishment by providing a clear and predictable response to invalid input.

Conclusion

Understanding and applying these five design principles—SOLID, DRY, KISS, YAGNI, and the Principle of Least Astonishment—can significantly improve the quality of your software. By following these principles, you can create applications that are easier to maintain, extend, and understand, ultimately leading to more robust and reliable software.

References

SOLID Principles: SOLID Principles in C#

DRY Principle: Don't Repeat Yourself (DRY)

KISS Principle: KISS Principle

YAGNI Principle: YAGNI

Principle of Least Astonishment: Principle of Least Astonishment

Top comments (0)