Published on

Software Development Principles

Authors

1- Why Knowing and Following Principles is Important

When you are a software developer, you are like an architect who is not only creating the plan of the house but also laying the bricks, doing the electricity, and the painting. If you want to be good at it, you need to have a good overall vision of the project and be skilled at crafting things on a granular level.

Manipulating the syntax is the most granular thing; it is often the first thing we get in touch with when we start to learn a new language. However, tackling the problem of learning a new language by only learning its syntax will certainly be limiting if you don’t understand the good principles your overall code should follow.

Learning these principles is more beneficial than learning specific syntax because it allows you to better understand how any type of code works. By mastering these principles, you will be able to apply them repeatedly in any situation.

2- Key Software Development Principles

2.1- Single Responsibility Principle (SRP)

2.1.1- Justification

  • A class should have only one reason to change. In other words it should be responsible only of one thing. This reduces the risk of unintentional side effects and makes the class easier to understand and maintain.

2.1.2- Implementation

  • Identify the single responsibility of the class.
  • Ensure each class handles only one part of the functionality.
  • Refactor classes that handle multiple responsibilities into smaller, focused classes.

2.1.3- Example

Initial State:

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

    def send_email(self, message):
        # Code to send email
        pass

    def save(self):
        # Code to save user to database
        pass

Comment: The User class has multiple responsibilities: handling user data, sending emails, and saving to the database.

Final State:

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

class EmailService:
    def send_email(self, user, message):
        # Code to send email
        pass

class UserRepository:
    def save(self, user):
        # Code to save user to database
        pass

Comment: Responsibilities are separated into different classes: User handles user data, EmailService handles email functionality, and UserRepository handles database operations.

2.2- Open/Closed Principle (OCP)

2.2.1- Justification

  • Software entities should be open for extension but closed for modification. This allows the system to be extended without altering existing code, reducing the risk of introducing bugs.

2.2.2- Implementation

  • Use interfaces or abstract classes to allow new functionality to be added.
  • Follow design patterns like Strategy, Factory, or Decorator to achieve extension without modification.

2.2.3- Example

Initial State:

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

    def process_credit_payment(self, amount):
        # Process credit payment
        pass

    def process_paypal_payment(self, amount):
        # Process PayPal payment
        pass

Comment: Adding a new payment method requires modifying the PaymentProcessor class.

Final State:

class PaymentProcessor:
    def process_payment(self, payment_method, amount):
        payment_method.process(amount)

class PaymentMethod:
    def process(self, amount):
        raise NotImplementedError

class CreditPayment(PaymentMethod):
    def process(self, amount):
        # Process credit payment
        pass

class PayPalPayment(PaymentMethod):
    def process(self, amount):
        # Process PayPal payment
        pass

Comment: New payment methods can be added without modifying the PaymentProcessor class by extending the PaymentMethod class.

2.3- Liskov Substitution Principle (LSP)

2.3.1- Justification

  • Justification: Subtypes must be substitutable for their base types without altering the correctness of the program. This ensures that derived classes can be used in place of base classes without issues.

2.3.2- Implementation

  • Implementation:
    • Ensure derived classes extend the functionality of base classes without changing their behavior.
    • Avoid overriding methods in a way that alters the expected behavior of the base class.

2.3.3- Example

Initial State:

class Bird:
    def fly(self):
        pass

class Sparrow(Bird):
    def fly(self):
        # Sparrow flying logic
        pass

class Ostrich(Bird):
    def fly(self):
        raise Exception("Ostrich can't fly")

Comment: Ostrich violates LSP because it cannot fly, which is expected behavior for Bird. The Ostrich change the expected behavior of the fly method.

Final State:

class Bird:
    pass

class FlyingBird(Bird):
    def fly(self):
        pass

class Sparrow(FlyingBird):
    def fly(self):
        # Sparrow flying logic
        pass

class Ostrich(Bird):
    pass

Comment: Bird is split into Bird and FlyingBird, so Ostrich doesn't inherit fly method it cannot support.

2.4- Interface Segregation Principle (ISP)

2.4.1- Justification

  • Clients should not be forced to depend on interfaces they do not use. This reduces dependencies and makes the system more modular.

2.4.2- Implementation

  • Create small, specific interfaces rather than large, general-purpose ones.
  • Ensure classes implement only the interfaces they need.

2.4.3- Example

Initial State:

class WorkerInterface:
    def work(self):
        pass

    def eat(self):
        pass

class Worker(WorkerInterface):
    def work(self):
        # Working logic
        pass

    def eat(self):
        # Eating logic
        pass

class Robot(WorkerInterface):
    def work(self):
        # Working logic
        pass

    def eat(self):
        raise Exception("Robots don't eat")

Comment: Robot is forced to implement eat method it doesn't need.

Final State:

class Workable:
    def work(self):
        pass

class Eatable:
    def eat(self):
        pass

class Worker(Workable, Eatable):
    def work(self):
        # Working logic
        pass

    def eat(self):
        # Eating logic
        pass

class Robot(Workable):
    def work(self):
        # Working logic
        pass

Comment: Separate interfaces Workable and Eatable are created, allowing Robot to only implement Workable.

2.5- Dependency Inversion Principle (DIP)

2.5.1- Justification

  • High-level modules should not depend on low-level modules; both should depend on abstractions. This reduces coupling and makes the system more flexible.

2.5.2- Implementation

  • Depend on abstractions (e.g., interfaces or abstract classes) rather than concrete implementations.
  • Use dependency injection to provide dependencies from outside the class.

2.5.3- Example

Initial State:

class LightBulb:
    def turn_on(self):
        pass

    def turn_off(self):
        pass

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

    def operate(self, state):
        if state == "ON":
            self.lightbulb.turn_on()
        else:
            self.lightbulb.turn_off()

Comment: Switch depends directly on LightBulb.

Final State:

class Switchable:
    def turn_on(self):
        pass

    def turn_off(self):
        pass

class LightBulb(Switchable):
    def turn_on(self):
        pass

    def turn_off(self):
        pass

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

    def operate(self, state):
        if state == "ON":
            self.device.turn_on()
        else:
            self.device.turn_off()

Comment: Switch depends on Switchable interface, allowing any device implementing Switchable to be used.

2.6- DRY (Don't Repeat Yourself)

2.6.1- Justification

  • Every piece of knowledge must have a single, unambiguous, authoritative representation within a system. This reduces duplication and improves maintainability.

2.6.2- Implementation

  • Identify and eliminate duplicate code.
  • Refactor common functionality into reusable methods or classes.

2.6.3- Example

Initial State:

def calculate_area_of_circle(radius):
    return 3.14 * radius * radius

def calculate_area_of_square(side):
    return side * side

def calculate_area_of_rectangle(length, width):
    return length * width

Comment: Separate functions for each shape's area calculation, leading to code duplication.

Final State:

def calculate_area(shape, *dimensions):
    if shape == 'circle':
        radius = dimensions[0]
        return 3.14 * radius * radius
    elif shape == 'square':
        side = dimensions[0]
        return side * side
    elif shape == 'rectangle':
        length, width = dimensions
        return length * width

Comment: Single function calculate_area handles different shapes, reducing code duplication.

2.7- KISS (Keep It Simple, Stupid)

2.7.1- Justification

  • Systems work best if they are kept simple rather than made complex. This reduces the potential for errors and makes the system easier to understand.

2.7.2- Implementation

  • Avoid unnecessary complexity and over-engineering.
  • Focus on clear, simple solutions that meet the requirements.

2.7.3- Example

Initial State:

def calculate_total(items):
    total = 0
    for item in items:
        if item['type'] == 'A':
            total += item['price'] * 1.1
        elif item['type'] == 'B':
            total += item['price'] * 1.2
        elif item['type'] == 'C':
            total += item['price'] * 1.3
    return total

Comment: Complex conditional logic for calculating total.

Final State:

def calculate_total(items):
    total = 0
    price_multiplier = {'A': 1.1, 'B': 1.2, 'C': 1.3}
    for item in items:
        total += item['price'] * price_multiplier.get(item['type'], 1)
    return total

Comment: Simplified logic by using a dictionary for price multipliers.

2.8- YAGNI (You Aren't Gonna Need It)

2.8.1- Justification

  • Don't implement something until it is necessary. This avoids adding unused features that complicate the codebase.

2.8.2- Implementation

  • Implement functionality only when it is needed.
  • Resist the urge to add features that are not currently required.

2.8.3- Example

Initial State:

class ReportGenerator:
    def generate_pdf_report(self):
        pass

    def generate_excel_report(self):
        pass

    def generate_html_report(self):
        pass

## Currently only PDF report is needed
report_generator = ReportGenerator()
report_generator.generate_pdf_report()

Comment: Methods for Excel and HTML reports are unnecessary at this stage.

Final State:

class ReportGenerator:
    def generate_pdf_report(self):
        pass

## Only implement what is needed
report_generator = ReportGenerator()
report_generator.generate_pdf_report()

Comment: Only the needed PDF report method is implemented.

2.9- Separation of Concerns (SoC)

2.9.1- Justification

  • Different concerns or aspects of the software should be separated from each other. This makes the system easier to manage and evolve.

2.9.2- Implementation

  • Structure code into distinct modules, each handling a specific concern.
  • Ensure each module has a well-defined purpose and interacts with others through clear interfaces.

2.9.3- Example

Initial State:

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

    def save(self):
        # Code to save user to database
        pass

    def send_email(self, message):
        # Code to send email
        pass

Comment: User class handles multiple concerns: user data, saving, and emailing.

Final State:

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

class UserRepository:
    def save(self, user):
        # Code to save user to database
        pass

class EmailService:
    def send_email(self, user, message):
        # Code to send email
        pass

Comment: Separate classes handle different concerns: User, UserRepository, and EmailService.

2.10- Law of Demeter (LoD)

2.10.1- Justification

  • Justification: A module should not know about the internal details of the objects it manipulates. This reduces dependencies and makes the system more robust.

2.10.2- Implementation

  • Follow the principle of "talk to friends, not strangers."
  • Ensure objects interact only with immediate "friends" (e.g., their own methods, objects created by them, or objects passed to them).

2.10.3- Example

Initial State:

class Engine:
    def start(self):
        pass

class Car:
    def __init__(self):
        self.engine = Engine()

class Driver:
    def __init__(self, car):
        self.car = car

    def start_car(self):
        self.car.engine.start()

Comment: Driver is directly accessing the engine of the car, which violates the Law of Demeter.

Final State:

class Engine:
    def start(self):
        print("Engine started")

class Car:
    def __init__(self):
        self.engine = Engine()

    def start_engine(self):
        self.engine.start()

class Driver:
    def __init__(self, car):
        self.car = car

    def start_car(self):
        self.car.start_engine()

Comment: Driver now interacts only with Car, and Car handles its own internal details, adhering to the Law of Demeter.

2.11- Composition over Inheritance

2.11.1- Justification

  • Favor composition of objects over inheritance to achieve code reuse. This promotes flexibility and reduces the risk of tight coupling.

2.11.2- Implementation

  • Use composition to assemble behaviors from different classes.
  • Prefer interfaces and delegate functionality rather than relying on class inheritance.

2.11.3- Example

Initial State:

class Animal:
    def eat(self):
        pass

    def sleep(self):
        pass

class Bird(Animal):
    def fly(self):
        pass

class Fish(Animal):
    def swim(self):
        pass

Comment: Inheritance is used to add fly and swim methods to Bird and Fish.

Final State:

class Animal:
    def eat(self):
        pass

    def sleep(self):
        pass

class Flyable:
    def fly(self):
        pass

class Swimmable:
    def swim(self):
        pass

class Bird(Animal, Flyable):
    pass

class Fish(Animal, Swimmable):
    pass

Comment: Composition is used by creating separate Flyable and Swimmable classes, which are then composed with Animal.

2.12- Encapsulation

2.12.1- Justification

  • Encapsulate what varies. Hide the internal state and require all interaction to be performed through an object's methods. This protects the integrity of the object's state.

2.12.2- Implementation

  • Use private fields and provide public methods for access and modification.
  • Ensure that the internal representation of the object is hidden from the outside.

2.12.3- Example

Initial State:

class Account:
    def __init__(self, balance):
        self.balance = balance

account = Account(1000)
account.balance = -500  # Direct modification

Comment: Direct modification of balance can lead to invalid state.

Final State:

class Account:
    def __init__(self, balance):
        self.__balance = balance

    def get_balance(self):
        return self.__balance

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount

account = Account(1000)
account.deposit(500)
account.withdraw(200)

Comment: balance is encapsulated, and access is controlled via methods.

2.13- Principle of Least Astonishment (POLA)

2.13.1- Justification

  • Justification: A component should behave in a way that least surprises the user. This makes the system more intuitive and reduces the learning curve.

2.13.2- Implementation

  • Implementation:
    • Follow common conventions and standards.
    • Ensure that the behavior of the system is predictable and consistent.

2.13.3- Example

Initial State:

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def get_age(self):
        return self.name  # Astonishing behavior: returning name instead of age

Comment: The method get_age returns name, which is unexpected.

Final State:

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def get_age(self):
        return self.age

Comment: get_age now returns the correct attribute, age, aligning with user expectations.

2.14- Start Simple, Build Complexity

2.14.1- Justification

  • Justification: Begin with the simplest solution that works and gradually introduce complexity as needed. This helps manage complexity and reduces potential errors.

2.14.2- Implementation

  • Implementation:
    • Focus on creating a simple, functional solution first.
    • Incrementally add complexity as requirements evolve.

2.14.3- Example

2.15- Clear Objectives

2.15.1- Justification

  • Justification: Clearly understand and define what you want to achieve. This ensures development efforts are aligned with the desired outcomes.

2.15.2- Implementation

  • Implementation:
    • Define clear, measurable objectives before starting development.
    • Regularly review objectives to ensure they are being met.

2.15.3- Example

2.16- Easy to Maintain

2.16.1- Justification

  • Justification: Structure your code to be easy to maintain by limiting interdependencies and creating modular, sandboxed components. This makes the system more robust and easier to update.

2.16.2- Implementation

  • Implementation:
    • Modularize code to create independent, self-contained components.
    • Use interfaces or abstract classes to manage dependencies.

2.16.3- Example

2.17- Limit Complexity

2.17.1- Justification

  • Justification: Strive to keep the codebase simple and refactor as necessary to maintain simplicity. This reduces cognitive load and makes the system easier to understand.

2.17.2- Implementation

  • Implementation:
    • Regularly refactor code to improve simplicity.
    • Avoid unnecessary features and over-engineering.

2.17.3- Example

2.18- Testing

2.18.1- Justification

  • Justification: Implement thorough testing to catch issues early and ensure new changes don't break existing functionality. This ensures reliability and stability.

2.18.2- Implementation

  • Implementation:
    • Write unit tests for individual components.
    • Implement integration tests to verify the interactions between components.
    • Use automated testing tools to ensure tests are run regularly.

2.18.3- Example

2.19- Limit Dependencies

2.19.1- Justification

  • Justification: Minimize external dependencies to reduce the risk of external failures affecting your system. This enhances robustness and longevity.

2.19.2- Implementation

  • Implementation:
    • Evaluate and limit the use of external libraries and frameworks.
    • Ensure that dependencies are well-documented and justified.

2.19.3- Example

2.20- Documentation

  • Justification: Document the overall logic and processes within your code. This improves maintainability, knowledge transfer, and collaboration.
  • Implementation:
    • Write clear and concise documentation for each module and component.
    • Ensure that documentation is kept up-to-date with code changes.
    • Use tools like markdown files, wikis, or dedicated documentation platforms.

3- Principles and software development architecture

Software architecture plays a crucial role in implementing design principles to ensure systems are robust, maintainable, and scalable. Here’s a breakdown of how software architectures address these principles and which ones require careful implementation by developers.

3.1- The different software development architecture

Sure! Let's go through several common software development architectures and explain how they address the principles mentioned.

3.1.1- Layered (N-Tier) Architecture

Description: This architecture divides the system into layers, typically including presentation, business logic, and data access layers.

Principles Addressed:

  • Single Responsibility Principle (SRP): Each layer has a specific responsibility, such as handling user interface logic, business rules, or data access.
  • Separation of Concerns (SoC): Different concerns are separated into distinct layers.
  • Encapsulation: Each layer encapsulates its own functionality, exposing only necessary interfaces to the other layers.
  • KISS (Keep It Simple, Stupid): Layers simplify the system by organizing related functionality.
  • DRY (Don't Repeat Yourself): Common functionalities can be abstracted into services within the business logic or data access layers.

Implementation Responsibility:

  • Developers must ensure that each layer adheres to its defined responsibility and does not cross into other layers' concerns.
  • Proper interfaces and abstractions must be maintained between layers.

3.1.2- Microservices Architecture

Description: This architecture structures the application as a collection of loosely coupled services, each responsible for a specific business capability.

Principles Addressed:

  • Single Responsibility Principle (SRP): Each microservice is designed to handle a specific business function.
  • Open/Closed Principle (OCP): Microservices can be extended with new functionality without modifying existing services.
  • Separation of Concerns (SoC): Each microservice handles a distinct aspect of the application, promoting modularity.
  • Encapsulation: Microservices encapsulate their internal logic and data, exposing functionality through APIs.
  • Dependency Inversion Principle (DIP): Services depend on abstractions (APIs) rather than concrete implementations.
  • Limit Dependencies: Services are independent and interact through well-defined APIs, minimizing dependencies.

Implementation Responsibility:

  • Developers need to ensure that each microservice adheres to its defined responsibility and interacts with other services through well-defined interfaces.
  • Proper service boundaries and communication protocols must be established.

3.1.3- Service-Oriented Architecture (SOA)

Description: Similar to microservices, SOA structures the application as a collection of services that communicate over a network.

Principles Addressed:

  • Single Responsibility Principle (SRP): Services are designed to handle specific business capabilities.
  • Separation of Concerns (SoC): Different services handle different aspects of the application.
  • Encapsulation: Services encapsulate their logic and data, interacting through service contracts.
  • Open/Closed Principle (OCP): New services can be added without modifying existing ones.
  • Interface Segregation Principle (ISP): Services provide focused, specific interfaces for their functionality.
  • Dependency Inversion Principle (DIP): Services depend on service contracts (abstract definitions) rather than concrete implementations.

Implementation Responsibility:

  • Developers must define clear service contracts and ensure that services adhere to their responsibilities.
  • Proper governance and communication protocols must be maintained.

3.1.4- Event-Driven Architecture

Description: This architecture uses events to trigger communication between decoupled components or services.

Principles Addressed:

  • Single Responsibility Principle (SRP): Each event handler or processor handles specific events or tasks.
  • Open/Closed Principle (OCP): New event handlers can be added to handle new events without modifying existing ones.
  • Separation of Concerns (SoC): Different components handle different types of events.
  • Encapsulation: Components encapsulate their functionality, reacting to events and performing tasks independently.
  • Law of Demeter (LoD): Components only interact through events, minimizing knowledge of other components.

Implementation Responsibility:

  • Developers must ensure that event handlers are designed to handle specific events and interact with other components only through events.
  • Proper event definitions and handling mechanisms must be established.

3.1.5- Component-Based Architecture

Description: This architecture divides the system into reusable, self-contained components that interact through well-defined interfaces.

Principles Addressed:

  • Single Responsibility Principle (SRP): Each component handles a specific part of the functionality.
  • Separation of Concerns (SoC): Different components handle different concerns.
  • Encapsulation: Components encapsulate their internal logic and state, exposing functionality through interfaces.
  • Composition over Inheritance: Components can be composed to form larger systems, promoting reuse.
  • Interface Segregation Principle (ISP): Components provide specific, focused interfaces.

Implementation Responsibility:

  • Developers must design components to adhere to their specific responsibilities and interact through well-defined interfaces.
  • Proper component composition and interaction patterns must be maintained.

3.1.6- Hexagonal (Ports and Adapters) Architecture

Description: This architecture decouples the core business logic from external systems (such as user interfaces or databases) through ports and adapters.

Principles Addressed:

  • Single Responsibility Principle (SRP): The core logic handles business rules, while adapters handle external interactions.
  • Open/Closed Principle (OCP): Adapters can be added or modified without changing the core logic.
  • Separation of Concerns (SoC): The core business logic is separated from external concerns.
  • Encapsulation: The core logic is encapsulated within a central domain, interacting with the outside world through ports.
  • Dependency Inversion Principle (DIP): The core logic depends on abstractions (ports), not concrete implementations (adapters).

Implementation Responsibility:

  • Developers need to ensure that the core logic is isolated and interacts with external systems through well-defined ports and adapters.
  • Proper design of ports and adapters to facilitate decoupling.

3.1.7- CQRS (Command Query Responsibility Segregation)

Description: This architecture separates the handling of commands (write operations) and queries (read operations).

Principles Addressed:

  • Single Responsibility Principle (SRP): Commands handle state-changing operations, while queries handle read-only operations.
  • Separation of Concerns (SoC): Different models for handling reads and writes.
  • Encapsulation: Read and write models encapsulate their own logic and state.
  • Open/Closed Principle (OCP): New command and query handlers can be added without affecting existing ones.

Implementation Responsibility:

  • Developers must design separate models and handlers for commands and queries.
  • Proper synchronization and consistency mechanisms must be established between read and write models.

3.1.8- Summary

Architectures and Principles:

ArchitecturePrinciples Addressed
Layered (N-Tier)SRP, SoC, Encapsulation, KISS, DRY
MicroservicesSRP, OCP, SoC, Encapsulation, DIP, Limit Dependencies
Service-Oriented ArchitectureSRP, SoC, Encapsulation, OCP, ISP, DIP
Event-DrivenSRP, OCP, SoC, Encapsulation, LoD
Component-BasedSRP, SoC, Encapsulation, Composition over Inheritance, ISP
Hexagonal (Ports and Adapters)SRP, OCP, SoC, Encapsulation, DIP
CQRSSRP, SoC, Encapsulation, OCP

3.2- Principles Largely Handled by Architecture

  1. Separation of Concerns (SoC): Architectures like microservices and layered architectures inherently separate different concerns.

  2. Encapsulation: Service-oriented architectures and microservices naturally encapsulate logic within services.

  3. Interface Segregation Principle (ISP): Service definitions in SOA and microservices promote small, focused interfaces.

  4. Dependency Inversion Principle (DIP): Use of IoC containers and service abstractions in modern architectures.

3.3- Principles Requiring Developer Attention

  1. Single Responsibility Principle (SRP): Developers must ensure classes and methods within services follow SRP.

  2. Open/Closed Principle (OCP): Developers need to design extensible modules and components.

  3. Liskov Substitution Principle (LSP): Developers must ensure that derived classes follow the contracts of their base classes.

  4. DRY (Don’t Repeat Yourself): Developers must actively identify and refactor duplicated code.

  5. KISS (Keep It Simple, Stupid): Developers should avoid unnecessary complexity.

  6. YAGNI (You Aren’t Gonna Need It): Developers need to implement features only when they are required.

  7. Law of Demeter (LoD): Developers must design methods and interactions to minimize knowledge of internal object details.

  8. Composition over Inheritance: Developers should prefer composition over inheritance in class design.

  9. Principle of Least Astonishment (POLA): Developers should ensure their code and APIs behave in a predictable an d intuitive manner.

In summary, while architectural choices can provide a strong foundation for adhering to these principles, developers must actively apply and enforce these principles in their day-to-day coding practices to ensure the principles are fully realized.

4- Conclusion

Understanding and following software development principles is crucial for creating high-quality, maintainable, and scalable software. As a software developer, you are not just a coder but an architect who builds complex systems from the ground up. By mastering principles like the Single Responsibility Principle (SRP), Open/Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP), you lay a strong foundation for your code.

Ultimately, following these principles not only improves the quality of your code but also enhances your reputation as a skilled and thoughtful developer. Whether you are working on a small project or a large enterprise application, these principles will guide you in creating software that stands the test of time.