- Published on
Software Development Principles
- Authors
- Name
- Valentin P
- @ValentinP43
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:
Architecture | Principles Addressed |
---|---|
Layered (N-Tier) | SRP, SoC, Encapsulation, KISS, DRY |
Microservices | SRP, OCP, SoC, Encapsulation, DIP, Limit Dependencies |
Service-Oriented Architecture | SRP, SoC, Encapsulation, OCP, ISP, DIP |
Event-Driven | SRP, OCP, SoC, Encapsulation, LoD |
Component-Based | SRP, SoC, Encapsulation, Composition over Inheritance, ISP |
Hexagonal (Ports and Adapters) | SRP, OCP, SoC, Encapsulation, DIP |
CQRS | SRP, SoC, Encapsulation, OCP |
3.2- Principles Largely Handled by Architecture
Separation of Concerns (SoC): Architectures like microservices and layered architectures inherently separate different concerns.
Encapsulation: Service-oriented architectures and microservices naturally encapsulate logic within services.
Interface Segregation Principle (ISP): Service definitions in SOA and microservices promote small, focused interfaces.
Dependency Inversion Principle (DIP): Use of IoC containers and service abstractions in modern architectures.
3.3- Principles Requiring Developer Attention
Single Responsibility Principle (SRP): Developers must ensure classes and methods within services follow SRP.
Open/Closed Principle (OCP): Developers need to design extensible modules and components.
Liskov Substitution Principle (LSP): Developers must ensure that derived classes follow the contracts of their base classes.
DRY (Don’t Repeat Yourself): Developers must actively identify and refactor duplicated code.
KISS (Keep It Simple, Stupid): Developers should avoid unnecessary complexity.
YAGNI (You Aren’t Gonna Need It): Developers need to implement features only when they are required.
Law of Demeter (LoD): Developers must design methods and interactions to minimize knowledge of internal object details.
Composition over Inheritance: Developers should prefer composition over inheritance in class design.
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.