Software Design Principles
Binoy Vijayan
Posted on December 26, 2023
A. Traits of good software design
- Modularity
- Maintainability
- Performance
- Portability
- Usability
- Trackability
- Easy Deployment
A.1. Modularity
Modularity in programming refers to the practice of breaking down a program
into smaller, independent, and interchangeable modules. In Swift, modularity
can be achieved through the use of modules, classes, and functions. Here's a
simple example to illustrate modularity in Swift:
Let's say we want to create a simple program to manage a list of books. We
can create modules for different functionalities, such as adding books,
displaying book details, and searching for books.
i. Book.swift - Module for representing a book:
// Book.swift
struct Book {
var title: String
var author: String
var publicationYear: Int
}
ii. Library.swift - Module for managing a collection of books
// Library.swift
class Library {
var books: [Book] = []
func addBook(_ book: Book) {
books.append(book)
}
func displayBooks() {
for book in books {
print("Title: \(book.title), Author: \(book.author),
Year: \(book.publicationYear)")
}
}
func searchBooks(byAuthor author: String) -> [Book] {
return books.filter { $0.author == author }
}
}
iii. main.swift - The main program that uses the modules:
// main.swift
let book1 = Book(title: "The Swift Programming Language",
author: "Apple Inc.",
publicationYear: 2021)
let book2 = Book(title: "Clean Code",
author: "Robert C. Martin",
publicationYear: 2008)
let library = Library()
library.addBook(book1)
library.addBook(book2)
print("All Books:”)
library.displayBooks()
let authorToSearch = "Apple Inc."
let booksByAuthor = library.searchBooks(byAuthor: authorToSearch)
print("\nBooks by Author ‘\(authorToSearch)':")
for book in booksByAuthor {
print("Title: \(book.title), Year: \(book.publicationYear)")
}
Violation
Let's demonstrate a violation of modularity by not properly organising the code into separate modules. In this example, we'll put all the functionality in a single file, which is not
modular and can lead to difficulties in maintenance as the codebase grows.
// NonModularExample.swift
struct Book {
var title: String
var author: String
var publicationYear: Int
}
class Library {
var books: [Book] = []
func addBook(_ book: Book) {
books.append(book)
}
func displayBooks() {
for book in books {
print("Title: \(book.title),
Author: \(book.author),
Year: \(book.publicationYear)")
}
}
func searchBooks(byAuthor author: String) -> [Book] {
return books.filter { $0.author == author }
}
}
// Main Program
let book1 = Book(title: "The Swift Programming Language",
author: "Apple Inc.",
publicationYear: 2021)
let book2 = Book(title: "Clean Code",
author: "Robert C. Martin",
publicationYear: 2008)
let library = Library()
library.addBook(book1)
library.addBook(book2)
print("All Books:")
library.displayBooks()
let authorToSearch = "Apple Inc."
let booksByAuthor = library.searchBooks(byAuthor: authorToSearch)
print("\nBooks by Author '\(authorToSearch)':")
for book in booksByAuthor {
print("Title: \(book.title), Year: \(book.publicationYear)")
}
----------------------------------------------------------
A.2. Maintainability
Maintainability in code refers to how easily a codebase can be understood, modified, and extended over time. Writing maintainable code involves practices like code clarity, proper organisation, and documentation. For maintainability, consider the following practices:
- Clear Structure: Organise code into logical modules or classes, each with a specific responsibility.
- Meaningful Naming: Use descriptive names for variables, functions, and classes to make the code self-explanatory.
- Documentation: Include comments and documentation to explain complex parts of the code.
- Consistent Style: Follow consistent coding style and formatting guidelines to make the code visually coherent.
- Error Handling: Implement proper error handling to make the code robust and resilient.
- Testing: Include unit tests to ensure that the code behaves as expected during changes and updates.
// Product.swift - Module for representing a product
struct Product {
var name: String
var price: Double
}
// ShoppingCart.swift - Module for managing a shopping cart
class ShoppingCart {
var items: [Product] = []
func addProduct(_ product: Product) {
items.append(product)
}
func calculateTotal() -> Double {
return items.reduce(0) { $0 + $1.price }
}
}
// Main.swift - Main program using the modular approach
let product1 = Product(name: "Laptop", price: 1200.0)
let product2 = Product(name: "Headphones", price: 99.99)
let cart = ShoppingCart()
cart.addProduct(product1)
cart.addProduct(product2)
print("Shopping Cart:")
for item in cart.items {
print("Product: \(item.name), Price: $\(item.price)")
}
let total = cart.calculateTotal()
print("\nTotal Price: $\(total)")
Here's an example of Swift code that emphasises maintainability:
In the above example, the code is organised into separate modules (Product and ShoppingCart). Each module has a clear responsibility, making the code more understandable. The code follows Swift naming conventions, which enhances readability. Additionally, the use of meaningful variable and function names contributes to code clarity.
Violation
// Violation.swift - Non-maintainable example
struct P {
var n: String
var p: Double
}
class S {
var i: [P] = []
func a(_ p: P) {
i.append(p)
}
func c() -> Double {
return i.reduce(0) { $0 + $1.p }
}
}
let p1 = P(n: "Laptop", p: 1200.0)
let p2 = P(n: "Headphones", p: 99.99)
let s = S()
s.a(p1)
s.a(p2)
print("Shopping Cart:")
for item in s.i {
print("Product: \(item.n), Price: $\(item.p)")
}
let t = s.c()
print("\nTotal Price: $\(t)")
Let's create an example that violates some common principles of maintainability. In this example, we'll put everything in a single file without clear organisation or meaningful names.
In this example:
- Variable and function names are abbreviated, making them unclear (P, S, a, c, i, n, p, t).
- There's no separation of concerns; the entire program, including the data structure, logic, and presentation, is in a single file.
- The lack of comments or documentation makes it challenging to understand the purpose and functionality of the code.
- The structure of the code doesn't follow Swift conventions, which may lead to confusion for developers familiar with Swift best practices.
This code violates principles of maintainability, and as the codebase grows, it becomes more challenging to understand and modify. To enhance maintainability, it's crucial to follow best practices such as clear organization, meaningful naming, documentation, and adherence to coding conventions.
----------------------------------------------------------
A.3. Performance
Performance in software refers to how well a system executes its tasks and utilises resources, such as CPU, memory, and storage. Improving performance often involves optimising algorithms, data structures, and other aspects of code to make it run faster and use resources more efficiently. Below, I'll provide a simple Swift example demonstrating a performance improvement through the use of a more efficient algorithm.
Performance Improvement Example:
Let's consider a task where we need to find the sum of all even numbers in an array.
// Non-optimised version
func sumOfEvenNumbers(_ numbers: [Int]) -> Int {
var sum = 0
for number in numbers {
if number % 2 == 0 {
sum += number
}
}
return sum
}
let numbersArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
let result = sumOfEvenNumbers(numbersArray)
print("Sum of even numbers: \(result)")
Non-Optimised Version
// Optimised version using higher-order functions
func sumOfEvenNumbers(_ numbers: [Int]) -> Int {
return numbers.filter { $0 % 2 == 0 }.reduce(0, +)
}
let numbersArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
let result = sumOfEvenNumbers(numbersArray)
print("Sum of even numbers: \(result)")
Optimised Version
In the optimised version, we use Swift's higher-order functions filter and reduce to achieve the same result with more concise and potentially more performant code. The filter function creates a new array containing only the even numbers, and reduce then calculates their sum. This approach leverages the Swift Standard Library's optimised implementations of these functions, which can lead to better performance compared to manually iterating over the array.
Keep in mind that performance improvements depend on the specific task and context, and it's essential to profile and measure performance gains to ensure the effectiveness of optimisations.
A.4. Portability
Portability in software development refers to the ease with which code can be transferred or adapted to different environments, platforms, or systems. Writing portable code is essential for ensuring that software can run seamlessly across various devices, operating systems, and architectures. Here are some practices and considerations related to portability.
Use Cross-Platform Libraries and APIs
Leverage cross-platform libraries and frameworks that are designed to work on multiple platforms.
Avoid Platform-Specific APIs:
Minimise the use of platform-specific APIs or features. If you need to interact with platform-specific functionality, consider using conditional compilation (#if os()) to isolate platform-specific code sections.
Check Availability and Versioning:
Use the @available attribute to check for the availability of APIs on different platforms and versions. This helps in handling differences between operating system versions.
Abstract Platform-Dependent Code:
Encapsulate platform-specific code into separate modules or classes, providing an abstraction layer that makes it easier to switch or extend support for different platforms.
Careful Handling of File Paths and URLs:
Be cautious when dealing with file paths and URLs. Use the appropriate APIs and consider using the FileManager and URL classes to manage file system operations in a platform-independent way.
Test on Different Platforms:
Regularly test your code on different platforms to identify and address any platform-specific issues early in the development process.
Consider Internationalisation:
Be mindful of internationalisation and localisation requirements. Ensure that your code supports different languages and regions.
Use cross platform dependancy manager:
If possible, consider using cross platform tool for dependency
management.
By following these practices, you can increase the portability of your code, making it easier to maintain and deploy on a variety of platforms. Keep in mind that achieving perfect portability may not always be feasible, especially when dealing with platform-specific features, but these practices can help strike a balance between portability and functionality.
Let's consider a simple example to demonstrate portability in Swift. In this example, we'll create a basic function that prints a message using platform-specific code for iOS and macOS.
import Foundation
func printPlatformSpecificMessage() {
#if os(iOS)
print("Running on iOS")
#elseif os(macOS)
print("Running on macOS")
#else
print("Running on an unsupported platform")
#endif
}
printPlatformSpecificMessage()
In this example:
- The #if os(iOS) and #elseif os(macOS) directives are used to conditionally compile platform-specific code.
- If the code is compiled for iOS, it prints "Running on iOS.”
- If the code is compiled for macOS, it prints "Running on macOS.”
- If the code is compiled for any other platform, it prints "Running on an unsupported platform."
This demonstrates a simple way to handle platform-specific code in a portable manner. However, keep in mind that portability might not always be achieved through simple conditional compilation, especially when dealing with more complex features or dependencies. In such cases, additional abstraction layers and platform-agnostic design principles may be needed.
Testing and running the code on different platforms or simulators will help ensure that it behaves as expected across various environments.
Violation
Let's consider an example that violates portability by using platform-specific APIs directly without proper abstraction:
The conditional compilation at the end of the code chooses the appropriate function based on the platform. This violates portability because the code relies directly on platform-specific.
import UIKit
func showAlertOniOS() {
let alertController = UIAlertController(
title: "iOS Alert",
message: "This is an iOS-specific alert.",
preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "OK",
style: .default,
handler: nil))
// Assuming this code is meant to run only on iOS
UIApplication.shared
.keyWindow?
.rootViewController?
.present(alertController,
animated: true,
completion: nil)
}
// This function uses AppKit, which is macOS-specific
func showMessageBoxOnMacOS() {
let alert = NSAlert()
alert.messageText = "macOS Alert"
alert.informativeText = "This is a macOS-specific alert."
alert.addButton(withTitle: "OK")
// Assuming this code is meant to run only on macOS
alert.runModal()
}
// Example of using platform-specific functions
#if os(iOS)
showAlertOniOS()
#elseif os(macOS)
showMessageBoxOnMacOS()
#endif
In the above example:
showAlertOniOS uses UIAlertController, which is specific to
iOS.showMessageBoxOnMacOS uses NSAlert, which is specific to macOS.
APIs without providing an abstraction layer. A more portable approach would involve creating a common interface or abstraction for displaying alerts and using that interface throughout the application. This way, the specific implementation details for each platform can be encapsulated, making it easier to switch between platforms or extend support for additional platforms.
----------------------------------------------------------
A.5. Usability
Let's create a smaller example that focuses on the logic of a simple command-line application. In this example, we'll implement a basic calculator with clear input validation and user feedback.
In this example:
Clarity:
The Operation enum makes it clear which operation is being
performed.
The performCalculation function takes two numbers and an operation as parameters, providing a clear and modular structure.
Efficiency:
The calculator logic is straightforward, allowing users to quickly understand and use it.
Feedback:
The performCalculation function provides feedback for specific
scenarios, such as division by zero, to prevent errors and improve user experience.
This is a console-based example, but the principles of clarity, efficiency, and feedback apply regardless of the type of application. In a graphical user interface (GUI) application or a web application, similar principles would be applied to the presentation layer, but the underlying logic and principles of usability remain consistent.
import Foundation
enum Operation {
case add, subtract, multiply, divide
}
func performCalculation(firstNumber: Double,
secondNumber: Double,
operation: Operation) -> Double? {
switch operation {
case .add:
return firstNumber + secondNumber
case .subtract:
return firstNumber - secondNumber
case .multiply:
return firstNumber * secondNumber
case .divide:
guard secondNumber != 0 else {
// Provide feedback for division by zero
print("Error: Cannot divide by zero.")
return nil
}
return firstNumber / secondNumber
}
}
// Example of using the calculator
let number1 = 10.0
let number2 = 5.0
let chosenOperation = Operation.add
if let result = performCalculation(firstNumber: number1,
secondNumber: number2,
operation: chosenOperation) {
// Provide feedback to the user
print("Result of \(chosenOperation): \(result)")
}
Violation
import Foundation
enum Operation {
case add, subtract, multiply, divide
}
func performCalculation(firstNumber: Double,
secondNumber: Double,
operation: Operation) -> Double? {
switch operation {
case .add:
return firstNumber + secondNumber
case .subtract:
return firstNumber - secondNumber
case .multiply:
return firstNumber * secondNumber
case .divide:
guard secondNumber != 0 else {
// Provide feedback for division by zero
print("Error: Cannot divide by zero.")
return nil
}
return firstNumber / secondNumber
}
}
// Example of using the calculator
let number1 = 10.0
let number2 = 5.0
let chosenOperation = Operation.add
if let result = performCalculation(firstNumber: number1,
secondNumber: number2,
operation: chosenOperation) {
// Provide feedback to the user
print("Result of \(chosenOperation): \(result)")
}
Let's consider an example that violates usability principles by neglecting input validation and providing poor feedback in a simple command-line application.
In this example, we have several violations of usability principles:
Input Validation Violation:
The function performUnsafeCalculation does not validate input properly. It allows division by zero, and there's no mechanism to handle invalid operations.
Poor Feedback Violation:
There's no feedback to the user when an invalid operation is provided. Users won't know that they entered an incorrect operation or attempted to divide by zero.
Inefficient Handling Violation:
The use of a string for representing operations is less efficient and more error-prone compared to an enum. It's harder to maintain and prone to typos.
In a real-world scenario, such violations could lead to unexpected behaviour, errors, and a poor user experience. To improve usability, it's essential to validate input, provide meaningful feedback, and use clear and efficient mechanisms for user interactions.
----------------------------------------------------------
A.6.Trackability
Writing trackable code involves incorporating practices and patterns that make it easy to understand, trace, and maintain your codebase.
Below are some practices along with Swift code examples that enhance trackability:
Meaningful Variable and Function Names:
Use descriptive names for variables and functions that convey their purpose.
// Bad example
let x = 5
func f(a: Int, b: Int) -> Int {
return a + b
}
// Good example
let numberOfItems = 5
func calculateSum(of a: Int, and b: Int) -> Int {
return a + b
}
Use Enums for Constants:
Use enums to represent sets of related constants, improving readability and making it easier to update.
/ Bad example
let paymentMethodCreditCard = "CreditCard"
let paymentMethodPayPal = "PayPal"
// Good example
enum PaymentMethod {
case creditCard
case payPal
}
Comments for Context:
Add comments to explain complex logic or provide additional context.
// Bad example
func calculateSquare(_ number: Int) -> Int {
return number * number
}
// Good example
/// Calculates the square of a given number.
/// - Parameter number: The input number.
/// - Returns: The square of the input number.
func calculateSquare(_ number: Int) -> Int {
return number * number
}
Structured Error Handling:
Use Swift's Error type and structured error handling to provide clear feedback.
// Bad example
func divide(_ a: Int, by b: Int) -> Int {
if b == 0 {
print("Error: Division by zero")
return 0
}
return a / b
}
// Good example
enum DivisionError: Error {
case divisionByZero
}
func divide(_ a: Int, by b: Int) throws -> Int {
guard b != 0 else {
throw DivisionError.divisionByZero
}
return a / b
}
Modular Code:
Organise code into modules, classes, or functions that have a single responsibility.
// Bad example
func performComplexTask() {
// ...
}
// Good example
class DataProcessor {
func processData(_ data: Data) {
// ...
}
}
Testable Code:
Write code with testing in mind. Use dependency injection and protocols to make components easily testable.
// Bad example
func fetchDataFromNetwork() {
// ...
}
// Good example
protocol DataFetcher {
func fetchData() -> Data
}
class NetworkDataFetcher: DataFetcher {
func fetchData() -> Data {
// Fetch data from the network
// ...
return Data()
}
}
By incorporating these practices into your Swift code, you make it more readable, maintainable, and traceable. Clear code with meaningful names and well-structured logic facilitates collaboration and helps others understand the purpose and behaviour of your code.
----------------------------------------------------------
A.7. Easy Deployment
To make your code compatible for easy deployment, consider the following practices in Swift:
Use Environment Variables for Configuration:
Avoid hardcoding configuration values. Use environment variables to configure your application based on the deployment environment.
let databaseURL = ProcessInfo
.processInfo
.environment["DATABASE_URL"] ?? "default"
Logging:
Use a logging framework like SwiftLog to manage logs. Adjust the log levels based on the environment (e.g., more detailed logs in development, fewer in production).
import Logging
let logger = Logger(label: "com.yourapp")
logger.info("Application started")
Handle Dependencies with Swift Package Manager:
Explicitly declare and manage dependencies using Swift Package Manager (SPM) in your Package.swift file.
// Package.swift
// ...
dependencies: [
.package(url: "https://github.com/example/dependency.git",
from: "1.0.0"),
Use Dependency Injection:
Avoid using singletons or global states. Instead, use dependency injection to inject dependencies into your classes.
class DatabaseService {
// ...
}
class MyController {
let databaseService: DatabaseService
init(databaseService: DatabaseService) {
self.databaseService = databaseService
}
}
Configurable Endpoints:
If your application communicates with external services, make endpoints configurable. This allows you to switch between testing and production endpoints easily.
let apiEndpoint = ProcessInfo
.processInfo
.environment["API_ENDPOINT"] ?? "https://
api.example.com"
Handle Errors Gracefully:
Implement error handling strategies, and ensure your application can recover from errors gracefully. Log errors and provide meaningful error messages.
do {
// ... code that may throw an error
} catch {
logger.error("An error occurred: \(error)")
}
Separate Concerns - Single Responsibility Principle (SRP):
Ensure each component of your application has a single responsibility. This makes it easier to understand, test, and deploy.
Automated Tests:
Write comprehensive unit tests and integration tests to ensure your code behaves as expected. This helps catch issues early in the development process.
Versioning:
Follow semantic versioning for your project. Specify dependencies with version constraints to ensure compatibility.
// Package.swift
// ...
dependencies: [
.package(url: "https://github.com/example/dependency.git",
from: "1.0.0"),
],
targets: [
.target(name: "MyApp", dependencies: ["Dependency"]),
],
// ...
By incorporating these practices into your Swift code, you can create a codebase that is modular, configurable, and easier to deploy across different environments. These practices contribute to the overall maintainability and reliability of your software.
B. Bad Software Design
Symptoms of rotting design
1. Rigidity
- Difficult to change
- Every change causes a cascade of subsequent changes in dependant modules
- Small change required more work
2. Fragility
- Software breaks many places even for small changes
- Software breaks in areas where there is no conceptual relationships
3. Immobility
- Inability to re-use components
- Duplicate redundant code
4. Viscosity
- When the design preserving methods are harder to employ than the hacks, then the viscosity of the design is high.
B.1.Rigidity
Rigidity in a codebase refers to the difficulty of making changes or introducing new features because the existing code is resistant to modification. Symptoms of code rigidity often indicate that the code is not easily adaptable or extendable. Here are common symptoms of rigidity in a codebase:
High Cost of Changes:
Modifying existing code is time-consuming and requires significant effort. Even small changes may have cascading effects, necessitating modifications in multiple places throughout the codebase.
Frequent Code Duplication:
Developers resort to copying and pasting code to implement similar functionality in different parts of the codebase. This duplication occurs because the existing code structure is not easily reusable or extensible.
Overuse of Copy-Paste Programming:
Developers tend to duplicate entire blocks of code rather than creating reusable functions or modules. This practice leads to redundancy and makes the codebase less maintainable.
Excessive Use of Global Variables:
The code relies heavily on global variables or shared state, making it challenging to modify or extend without affecting other parts of the system. Global state introduces rigidity due to its widespread impact.
Resistance to Adopt New Technologies:
The codebase is resistant to adopting new technologies, frameworks, or libraries. This resistance may stem from the difficulty of integrating new tools into the existing rigid architecture.
Lack of Abstraction:
The code lacks proper abstraction, making it difficult to isolate and modify specific functionalities without affecting other parts of the system. Absence of clear interfaces or abstraction layers contributes to rigidity.
Monolithic Architecture:
The codebase follows a monolithic architecture with tightly coupled components. This makes it challenging to introduce modular changes without affecting the entire system.
Inflexible Design Patterns:
The codebase adheres to design patterns that are overly rigid and not well-suited to the evolving requirements. Design patterns that do not allow for flexibility can contribute to code rigidity.
Complex Control Flow:
The control flow of the code is overly complex, with many nested conditions and loops. This complexity makes it harder to understand, modify, and extend the code.
Difficulty in Adding New Features:
Introducing new features requires extensive modifications to existing code, and the codebase is not easily adaptable to changing requirements. This difficulty in feature addition is a key symptom of rigidity.
Reluctance to Refactor:
Developers are hesitant to refactor the code due to the perceived risk of unintended consequences. The fear of breaking existing functionality hinders efforts to improve the codebase.
Limited Testability:
The codebase is challenging to test in isolation, making it difficult to ensure that modifications do not introduce new bugs. Rigidity can hinder the creation and execution of effective unit tests.
Identifying these symptoms is crucial for addressing code rigidity. To mitigate rigidity, development teams should focus on adopting modular architectures, improving code organisation, introducing clear abstractions, and fostering a culture of continuous improvement and refactoring. Applying design principles like SOLID and striving for a clean, maintainable codebase helps alleviate the symptoms of code rigidity.
----------------------------------------------------------
B.2. Fragility
Fragility in a codebase can manifest in various symptoms, indicating that the code is prone to breaking easily when modifications are made. Identifying these symptoms is crucial for addressing fragility and improving the overall robustness of the software. Here are common symptoms of fragility in a codebase:
Frequent Bugs and Regressions:
Regular occurrences of bugs and regressions, especially after
seemingly unrelated changes, can be a clear sign of code fragility. Fragile code is more prone to unintended side effects.
Difficulty in Making Changes:
Developers express difficulty in making changes or adding new features without inadvertently breaking existing functionality. This may be due to the intricate relationships between different parts of the code.
Lack of Automated Tests:
Insufficient test coverage or the absence of automated tests makes it challenging to ensure that changes do not introduce new issues. The lack of a safety net from tests can indicate fragility.
High Cyclomatic Complexity:
High cyclomatic complexity, which measures the number of
independent paths through code, can make code more difficult to understand and modify. Complex code is often associated with fragility.
Tight Coupling:
Strong dependencies and tight coupling between components or
modules can lead to fragility. Changes in one area may have unintended consequences in other areas, creating a brittle codebase.
Global State Issues:
The use of global variables or shared state across the codebase can result in fragility. Modifications to global state may have unforeseen impacts, leading to bugs that are difficult to trace.
Inconsistent Code Style:
Inconsistencies in code style and structure may indicate a lack of standards or discipline in the development process. Inconsistent code can be more prone to errors and harder to maintain.
Lack of Documentation:
Inadequate or outdated documentation can contribute to fragility. Developers may struggle to understand the intended behavior of certain components, leading to unintentional modifications.
Rigid Design:
A design that is resistant to changes or modifications suggests rigidity and potential fragility. Code that is not easily extensible may break when new features are added.
Delayed Bug Discovery:
Bugs or issues are discovered late in the development process or after deployment. Fragile code may not manifest issues immediately, making it harder to detect and address problems early on.
High Rate of Merge Conflicts:
A high rate of merge conflicts during version control operations can be a symptom of code fragility. It may indicate that multiple developers are working on closely related or interdependent code sections.
Unreliable Third-Party Dependencies:
Reliance on outdated or unreliable third-party dependencies can introduce fragility. Changes or issues in external libraries may have unintended consequences on the codebase.
Identifying these symptoms early allows development teams to proactively address fragility through code refactoring, testing improvements, and adherence to best practices in software development. Regular code reviews, automated testing, and continuous integration practices are essential components of maintaining a resilient and non-fragile codebase.
----------------------------------------------------------
B.3. Immobility
Code immobility refers to the difficulty of reusing or extracting code for use in different contexts or projects. A codebase exhibiting symptoms of immobility is challenging to separate, refactor, or extend due to tightly coupled components or poor design. Here are common symptoms of code immobility:
Lack of Modularity:
The codebase lacks clear modules or components that can be easily separated and reused. Functions and classes may be tightly interwoven, making it difficult to extract and reuse specific functionality.
Tight Coupling:
Components within the codebase are highly dependent on each other, with tight coupling between classes or modules. Changes in one part of the system necessitate modifications in other, seemingly unrelated parts.
Absence of Clear Interfaces:
Components do not expose clear interfaces or contracts, making it challenging to use them independently. The lack of well-defined interfaces hinders the separation of concerns.
Dependence on Global State:
Heavy reliance on global variables or shared state across the codebase prevents the easy extraction and reuse of individual components. Components may depend on global state, making them less mobile.
Lack of Abstraction Layers:
The absence of abstraction layers or clear separation of concerns makes it difficult to isolate and extract specific functionalities. The code lacks the necessary levels of abstraction for reusability.
Inadequate Documentation:
Poor or outdated documentation makes it difficult for developers to understand the functionality of individual components. Lack of clear documentation hampers the extraction and reuse of code.
Specific Environment Dependencies:
Code components are tightly coupled to specific environments or frameworks, making them difficult to adapt to different contexts. Dependencies on specific libraries or technologies hinder mobility.
Low Code Cohesion:
Functions or classes within the codebase may lack cohesive
functionality, making it challenging to identify and extract self-contained units of code for reuse.
Resistance to Refactoring:
Developers are reluctant to refactor the codebase due to concerns about breaking existing functionality. This resistance hinders efforts to improve modularity and mobility.
Limited Use of Design Patterns:
The codebase does not leverage design patterns that facilitate
modularity and code reuse. Absence of common design patterns may hinder the mobility of code components.
No Clear API Design:
The lack of a well-defined Application Programming Interface (API) for internal components makes it challenging to separate and expose functionalities for external use.
Monolithic Architecture:
The codebase follows a monolithic architecture with minimal or no separation of concerns. This monolithic structure makes it difficult to extract and reuse specific parts independently.
Addressing code immobility involves introducing clear interfaces, encapsulating functionality into cohesive and modular components, and refactoring the codebase to improve its overall design. Embracing principles of modularity, such as the SOLID principles, and leveraging design patterns can significantly enhance the mobility of code components.
----------------------------------------------------------
B.4. Viscosity
Viscosity in a codebase refers to the degree of resistance to change. High viscosity implies that it is more difficult to make desirable changes, such as introducing new features or fixing bugs, leading to slower development and maintenance processes.
Here are symptoms of viscosity in a codebase:
Excessive Build Times:
Long build times can indicate viscosity. If developers avoid making changes due to the time it takes to rebuild and test the application, it can lead to delayed and infrequent deployments.
Complex Build and Deployment Processes:
Cumbersome build and deployment processes, often involving manual steps or complex configurations, contribute to viscosity. Developers may hesitate to make changes if the process is error-prone or time-consuming.
Ineffective Version Control:
A codebase with poorly managed version control, frequent conflicts, and a lack of branching strategies can result in viscosity. Developers may be reluctant to make changes if version control is perceived as a hindrance.
Cumbersome Testing Procedures:
If running tests is a slow and laborious process, developers may avoid making changes or writing new tests. Cumbersome testing procedures contribute to the viscosity of the development workflow.
High Technical Debt:
Accumulated technical debt, including outdated dependencies,
unaddressed code smells, and suboptimal design, can lead to increased viscosity. Developers may be hesitant to address technical debt, making the codebase harder to maintain.
Lack of Automation:
Insufficient automation in areas such as testing, deployment, and code analysis contributes to viscosity. Manual interventions and repetitive tasks hinder the development process.
Complex Configuration Management:
Complex and error-prone configuration management, especially when dealing with external services or environments, increases viscosity. Difficulty in managing configurations may deter developers from making changes.
Rigid Architecture:
A rigid or inflexible architecture that does not accommodate changes easily contributes to viscosity. Developers may be reluctant to modify the architecture, resulting in slower adaptation to evolving requirements.
Unclear Code Ownership:
Lack of clarity regarding code ownership and responsibility can lead to viscosity. Developers may hesitate to make changes in code sections they are not familiar with, delaying necessary modifications.
Inefficient Code Reviews:
If code reviews are time-consuming, ineffective, or perceived as a bottleneck, developers may avoid making changes. Inefficient code review processes contribute to the viscosity of the development workflow.
Limited Development Environment Isolation:
Difficulty in isolating development environments for testing or debugging purposes increases viscosity. Developers may be hesitant to experiment or test changes in a controlled environment.
Frequent Production Issues:
A high frequency of production issues and incidents can lead to viscosity. Developers may be cautious about making changes if there is a history of unexpected problems in the live environment.
Addressing viscosity requires a focus on improving development processes, optimising build and testing pipelines, reducing technical debt, automating repetitive tasks, and fostering a culture of continuous improvement. By addressing these symptoms, development teams can make the codebase more adaptable and encourage a smoother and more efficient development workflow.
C. Causes of design degradation
The degradation of design and code quality in a software project can result from various factors. Identifying these causes is crucial for preventing deterioration and maintaining a high-quality codebase. Here are common causes of degrading design and code quality:
Lack of Code Reviews:
Inconsistent or infrequent code reviews can lead to the introduction of suboptimal code. Code reviews help catch issues, enforce coding standards, and ensure that best practices are followed.
Inadequate Testing Practices:
Poor testing practices, including insufficient test coverage, lack of unit tests, and infrequent testing, can result in undetected bugs and lower code quality. Robust testing is essential for ensuring software reliability.
Rapid Changes and Short Deadlines:
Pressure to deliver features quickly without sufficient time for design and testing can lead to the accumulation of technical debt and compromise code quality. Urgency to meet deadlines may result in shortcuts and compromises.
Insufficient Documentation:
Inadequate or outdated documentation makes it challenging for
developers to understand the codebase. Lack of documentation can result in misunderstandings, leading to code modifications that degrade quality.
Failure to Refactor:
Neglecting code refactoring can result in the accumulation of technical debt. Refactoring is essential for improving code maintainability, readability, and adherence to best practices.
Absence of Coding Standards:
The absence of coding standards or a lack of adherence to established coding guidelines can lead to inconsistent and poor-quality code. Coding standards promote consistency and readability.
Limited Collaboration:
Lack of collaboration between team members can result in isolated decision-making and suboptimal code quality. Effective communication and collaboration are essential for maintaining a cohesive and high-quality codebase.
Inadequate Skill Development:
Failure to invest in ongoing skill development for the development team can result in outdated practices and technologies. Stagnant skills may contribute to the use of suboptimal coding techniques.
Scope Creep and Feature Bloat:
Uncontrolled scope creep and the continuous addition of features without proper planning can lead to a bloated and overly complex codebase. Feature bloat can negatively impact code maintainability and quality.
Lack of Code Ownership:
Absence of clear code ownership and accountability can result in a lack of responsibility for code quality. Developers may be less motivated to maintain or improve code they don't feel ownership over.
Ignoring Code Smells:
Ignoring code smells and warning signs of poor design choices can contribute to degradation. Addressing code smells promptly is crucial for preventing the accumulation of technical debt.
Inadequate Planning and Architecture:
Lack of upfront planning and consideration of architecture can lead to ad-hoc solutions and poor design choices. A well-thought-out architecture is crucial for scalability and maintainability.
Uncontrolled Technical Debt:
Accumulation of technical debt due to shortcuts, deferred refactoring, and temporary solutions can degrade code quality over time. Managing and addressing technical debt is essential for sustaining code quality.
Addressing these causes requires a proactive approach, including regular code reviews, effective testing strategies, continuous refactoring, adherence to coding standards, and a focus on collaborative and communicative development practices. Additionally, ongoing skill development and a
commitment to addressing technical debt contribute to maintaining a high level of code quality and design integrity.
D. Major Software Engineering Principles
D.1. Measure twice cut once
D.2. DRY (Don’t Repeat Yourself)
D.3. Occam’s Razor
D.4. KISS (Keep it simple stupid)
D.5. YAGNI (You Aren’t Gonna Need It)
D.6. Big Design Upfront
D.7. Avoid Premature Optimisation
D.8. Principle of Least Astonishment
D.9. Law of Demeter
D.10. SOLID [SRP, OCP, LSP, ISP, DIP]
D.1. Measure twice cut once
In the context of software development, the phrase "measure twice, cut once" can be interpreted as a reminder to carefully plan and consider your approach before implementing a solution. Here's how the concept applies to software development:
Requirement Analysis:
Measure: Clearly understand the requirements and specifications of the software project. Engage with stakeholders to gather all necessary information.
Measure Again: Double-check and validate the requirements. Ensure that you have a comprehensive understanding of what needs to be achieved.
Design Phase:
Measure: Plan and design the software architecture, user interface, and other components based on the gathered requirements.
Measure Again: Review and iterate on the design. Ensure that it aligns with the requirements and is scalable, maintainable, and efficient.
Coding:
Measure: Begin coding based on the finalized design. Write modular and well-documented code.
Measure Again: Before proceeding too far, review your code. Ensure that it follows coding standards, is readable, and meets the requirements.
Testing:
Measure: Conduct thorough testing, including unit tests, integration tests, and system tests.
Measure Again: Double-check the test results. Ensure that the software functions as expected and that all requirements are met.
Deployment:
Measure: Prepare for deployment, considering factors such as scalability, security, and user experience.
Measure Again: Before deploying to production, double-check your deployment plan. Ensure that you have backups and contingency plans in place.
By taking the time to carefully plan and review at each stage of software development, developers can reduce the likelihood of introducing errors and create more robust and reliable software. This approach aligns with the idea of being meticulous and thoughtful in your work, which can ultimately save time and resources in the long run.
----------------------------------------------------------
D.2. DRY (Don’t Repeat Yourself)
"DRY" stands for "Don't Repeat Yourself," and it is a software development principle that encourages developers to avoid duplicating code. The DRY principle is aimed at promoting code reusability, maintainability, and reducing redundancy in a software system. The idea is that each piece of knowledge
or logic should exist in a single place in the codebase.
Here are some key aspects of the DRY principle:
Code Duplication:
Problem: Duplicating code across a codebase can lead to
maintenance challenges. If a change is needed, you may have to update the same logic in multiple places, increasing the risk of inconsistencies and errors.
Single Source of Truth:
Solution: Instead of repeating the same code, create a single, centralized source for the logic or data. This way, if a change is required, it only needs to be made in one place, reducing the likelihood of introducing bugs.
Functions and Modules:
Problem: Redundant code often occurs when similar functionality is implemented in multiple locations.
Solution: Encapsulate common functionality in functions or modules. This not only reduces duplication but also makes the code more modular and easier to understand.
Abstraction:
Problem: Hardcoding values or business logic in multiple places can lead to inconsistencies and maintenance challenges.
Solution: Abstract out constants, configurations, or business rules into separate entities, making it easier to update them consistently.
Code Refactoring:
Problem: Over time, codebases can become cluttered with redundant code.
Solution: Regularly review the codebase and refactor as needed to eliminate redundancy. Look for opportunities to create reusable components or refactor existing ones to better adhere to the DRY principle.
Frameworks and Libraries:
Problem: Reimplementing common functionality that is already provided by frameworks or libraries.
Solution: Leverage existing frameworks and libraries to handle common tasks. This not only follows the DRY principle but also promotes consistency and reduces the amount of code developers need to maintain.
By adhering to the DRY principle, developers can create cleaner, more maintainable code that is less prone to errors. It encourages the creation of reusable components, promotes consistency, and makes it easier to manage and update software over time.
----------------------------------------------------------
D.3. Occam’s Razor
Occam's Razor, also spelled Ockham's Razor, is a principle attributed to the 14th-century logician and Franciscan friar William of Ockham. The principle is often expressed as "Entities should not be multiplied without necessity" or "The simplest explanation is usually the best one."
In the context of scientific and philosophical reasoning, Occam's Razor suggests that, when faced with multiple competing hypotheses or explanations, the one that makes the fewest assumptions and requires the least complexity is likely to be the most accurate. This principle encourages simplicity and parsimony in scientific theories and problem-solving.
Occam's Razor is not a strict rule but rather a heuristic or guideline. It doesn't guarantee that the simplest explanation is always correct, but it suggests that simpler explanations are often preferable until evidence supports a more complex one. Scientists and researchers often consider Occam's Razor when developing theories or models to avoid unnecessary complexity and better explain observed phenomena.
In the context of software development and problem-solving, Occam's Razor can be applied to guide developers and engineers in making design and implementation decisions.
Here are a few ways in which Occam's Razor can be relevant:
Simplicity in Code:
Write code that is clear, concise, and simple. Avoid unnecessary complexity in code. Choose the simplest solution that meets the requirements. This makes the code more readable, maintainable, and less prone to errors.
Architectural Design:
Keep the overall architecture of a system as simple as possible. When designing software systems, prefer straightforward architectures over unnecessarily complex ones. This can lead to easier maintenance, better scalability, and reduced chances of introducing bugs.
Algorithmic Choices:
Choose the simplest algorithm that satisfies performance and functionality requirements. Instead of opting for a complex algorithm by default, consider simpler algorithms that meet the performance needs. Complexity should be introduced only when necessary.
Feature Development:
Prioritise features based on necessity. When deciding which features to implement, prioritise those that directly address user needs or business requirements. Avoid adding features for the sake of complexity or completeness if they don't provide significant value.
Debugging and Issue Resolution:
Start by considering simple explanations for bugs or issues.
When troubleshooting problems, begin with the simplest and most likely explanations before exploring more complex possibilities. This can help in quickly identifying and resolving common issues.
User Interface Design:
Keep user interfaces simple and intuitive. Design user interfaces that are easy to understand and use. Avoid unnecessary features or complex navigational structures that may confuse users.
Documentation:
Keep documentation clear and concise.
Write documentation that is easy to understand. Avoid unnecessary jargon or overly complex explanations. Aim for clarity and simplicity in conveying information.
By applying Occam's Razor in software development, developers can create more maintainable, understandable, and reliable software. This principle complements the idea that simplicity often leads to better outcomes, both in terms of development and user experience.
----------------------------------------------------------
D.4. KISS (Keep it simple stupid)
"KISS," which stands for "Keep It Simple, Stupid," is a design principle in software development that encourages simplicity and clarity in design and implementation. The principle suggests that systems and designs should be kept as simple as possible, avoiding unnecessary complexity. The "stupid" in the acronym is a playful reminder to resist the temptation to overcomplicate things.
Here are key aspects of applying the KISS principle in software design:
Simplicity in Design:
- Choose the simplest design that meets the project's requirements.
- Avoid unnecessary features, components, or architectural complexities.
- A simpler design is often easier to understand, maintain, and troubleshoot.
Code Simplicity:
- Write code that is straightforward and easy to comprehend.
- Minimize code complexity by using clear and concise syntax, avoiding unnecessary abstractions, and favoring readability.
- Simple code is generally more maintainable and less error-prone
Avoiding Over-engineering:
- Resist the urge to over-engineer solutions.
- Solve the problem at hand without introducing unnecessary complexities.
- Over-engineering can lead to increased development time, maintenance challenges, and a higher likelihood of bugs.
User Interface Design:
- Keep user interfaces simple and intuitive.
- Design interfaces that are easy for users to understand and navigate.
- Avoid unnecessary features or convoluted workflows. A simple and intuitive
- UI contributes to a positive user experience.
Minimize Dependencies:
- Limit dependencies to essential ones.
- Choose dependencies wisely and avoid unnecessary ones.
- Too many dependencies can increase the complexity of a project and introduce potential issues with compatibility and maintenance.
Documentation Clarity:
- Keep documentation simple and easy to understand.
- Write documentation that is clear, concise, and targeted to the intended audience.
- Avoid unnecessary technical details that may confuse users or developers.
Iterative Development:
- Start with a simple version and iterate as needed.
- Begin with a minimal viable product (MVP) and enhance it based on feedback and evolving requirements. Avoid building extensive features upfront that may not be necessary.
- The KISS principle promotes a pragmatic approach to software design, emphasising the importance of simplicity for improved maintainability, understandability, and reliability. It aligns with the idea that simpler solutions are often more robust and easier to manage over the long term.
D.5. YAGNI (You Aren’t Gonna Need It)
"YAGNI" stands for "You Ain't Gonna Need It," and it is a software development principle that advises against implementing features or capabilities until they are actually needed. This principle is closely related to the concepts of simplicity and avoiding unnecessary work.
Here are key aspects of applying the YAGNI principle in software development:
Feature Prioritisation:
- Only implement features that are required for the current set of user needs or business requirements.
- Instead of adding features based on speculative future needs, focus on delivering the features that are necessary for the current iteration or release.
Avoiding Overengineering:
- Resist the temptation to build complex solutions for potential future scenarios.
- Solve the problem at hand without adding excessive complexity or functionality that may not be immediately utilised. Over-engineering can lead to wasted resources and increased maintenance overhead.
Iterative Development:
- Embrace an iterative and incremental development approach.
- Start with a minimal set of features (Minimum Viable Product) and iteratively add functionality based on user feedback and evolving requirements. This allows for a more responsive and adaptable development process.
Reducing Technical Debt:
- Minimise technical debt by avoiding unnecessary code or features.
- Keep the codebase clean by refraining from adding code that isn't essential.
- Unnecessary code can complicate maintenance and increase the risk of introducing bugs.
Resource Efficiency:
- Use development resources wisely.
- Allocate development time and resources to the most critical and high-priority tasks. Avoid spending time on features or enhancements that may not provide significant value.
Focus on Delivering Value:
- Prioritise delivering value to users and stakeholders.
- Concentrate on features and improvements that directly contribute to user satisfaction, business goals, or the overall success of the project. Avoid distractions that don't add immediate value.
- The YAGNI principle encourages a pragmatic and efficient approach to software development. By deferring the implementation of features until there is a clear and immediate need, developers can reduce unnecessary work, maintain a simpler codebase, and stay focused on delivering value to users and stakeholders.
----------------------------------------------------------
D.6. Big Design Upfront
"Big Design Upfront" (BDUF) is an approach to software development in which a significant amount of the system's design is completed before implementation begins. In this approach, the entire system is designed in detail, often resulting in extensive documentation, before any coding takes
place.
Key characteristics of the Big Design Upfront approach include:
Comprehensive Design Documentation:
BDUF typically involves creating extensive design documentation, including detailed specifications, diagrams, and plans that cover various aspects of the software architecture and functionality.
Early Decision-Making:
Many decisions about the system, such as architectural choices, technology stack, and major design patterns, are made upfront before the actual development work begins.
Waterfall Model:
BDUF is often associated with the Waterfall model, which is a linear and sequential software development methodology. In the Waterfall model, each phase (requirements, design, implementation, testing, deployment) must be completed before moving on to the next.
Rigidity:
BDUF can be perceived as rigid because changes to the design can be difficult and costly once the development phase has started. This approach assumes that the requirements are stable and well-understood from the beginning.
Potentially Long Planning Phase:
The planning and design phase in BDUF can be time-consuming,
especially for large and complex projects. This may lead to delays in starting the implementation phase.
While BDUF has its advantages, such as providing a clear roadmap and minimising uncertainties, it also has several drawbacks:
Inflexibility to Change:
Changes to requirements or design decisions can be challenging to accommodate once the development process is underway. This lack of flexibility can be problematic in dynamic or evolving project environments.
Potential for Over-Engineering:
Without the feedback loop from the development phase, there is a risk of over-designing and over-engineering the solution, leading to unnecessary complexity.
Delayed Delivery:
BDUF can result in delayed delivery of the final product to stakeholders since a significant amount of time is spent on planning and design before any tangible results are produced.
In contrast to BDUF, many modern software development methodologies, such as Agile and iterative approaches, emphasise adaptability, flexibility, and collaboration. These methodologies often involve iterative cycles of planning,
development, and testing, allowing for continuous feedback and adjustments throughout the project. The choice between BDUF and more agile approaches depends on the nature of the project, its requirements, and the preferences of the development team.
-------------------------------------------------------
D.7. Avoid Premature Optimisation
The principle "Avoid Premature Optimisation" encourages developers to refrain from optimising code or making performance enhancements before it's necessary. This concept is often attributed to Donald Knuth, who famously said, "We should forget about small efficiencies, say about 97% of the time: premature optimisation is the root of all evil."
Here are key aspects of avoiding premature optimisation in software development:
Focus on Correctness First:
- Prioritise writing correct and functional code before attempting to optimise for performance.
- Ensure that your code meets the specified requirements and functions correctly. Premature optimisation can distract from the primary goal of delivering a working solution.
Measure Before Optimising:
- Use profiling tools and metrics to identify performance bottlenecks before optimising code.
- Before making performance improvements, measure the actual performance of the application. Identify specific areas where optimisation is needed based on data rather than assumptions.
Maintainability Over Micro-Optimisations:
- Prioritise code readability and maintainability over micro-optimisations.
- Clear and understandable code is essential for collaboration and long-term maintenance. Avoid sacrificing readability for small performance gains that may not be significant.
Evolution of Requirements:
- Premature optimisation may be based on assumptions about the future that may not hold true.
- Requirements and priorities can change during the development process.
- Optimise when it becomes clear where performance improvements are genuinely needed.
Cost-Benefit Analysis:
- Evaluate the cost and benefits of optimisations.
- Consider the impact on development time, code complexity, and maintainability when deciding whether to optimise. Ensure that the benefits of optimisation outweigh the costs.
Iterative Optimisation:
- Optimise incrementally and iteratively.
- Optimize code gradually as the application evolves. Focus on high-impact areas first and revisit optimisation efforts as needed.
Use Profiling Tools:
- Identify bottlenecks using profiling tools.
- Profilers can help pinpoint areas of code that contribute most to the application's overall runtime. Use these tools to guide optimisation efforts effectively.
- By avoiding premature optimisation, developers can maintain a balance between delivering functional software in a timely manner and addressing performance concerns. The goal is to optimise intelligently based on actual needs rather than trying to optimise every aspect of the code from the beginning, which can lead to unnecessary complexity and reduced development speed.
D.8. Principle of Least Astonishment
The "Principle of Least Astonishment" (POLA), also known as the "Principle of Least Surprise" or "Rule of Least Astonishment," is a design guideline in software development and user interface design. The principle suggests that the behaviour of a system or a user interface element should align with users' expectations and minimise surprise or confusion.
Here are key aspects of the Principle of Least Astonishment:
User Expectations:
- Design elements should behave in a way that is consistent with users' expectations.
- When users interact with a system, they should not be surprised by unexpected or counterintuitive behaviours. Design elements such as buttons, menus, and navigation should behave in a way that users anticipate based on their prior experience.
Consistency:
- Maintain consistency in the design and behaviour of similar elements throughout the system.
- Similar actions or components within the system should have a consistent appearance and behaviour. For example, buttons with similar functions should look and behave alike.
Default Behaviour:
- Choose default behaviours that are likely to be the least surprising for the majority of users.
- When setting default configurations or behaviours, consider what is likely to be the most intuitive or commonly expected option. This reduces the need for users to make unnecessary adjustments.
Error Handling:
- Provide clear and understandable error messages.
- When errors occur, the error messages should convey useful information in a language that users can understand. Avoid cryptic messages that might astonish or confuse users.
Intuitive Design:
- Design interfaces and interactions in an intuitive manner.
- Arrange elements and features in a way that follows common patterns and conventions. Intuitive design reduces the learning curve for users and minimises the likelihood of astonishment.
User Feedback:
- Provide feedback to users about the outcome of their actions.
- Users should receive feedback indicating whether their actions were successful or if there are errors. Lack of feedback can lead to confusion and surprise.
Documentation:
- Ensure that documentation accurately reflects the behaviour of the system.
- Users should be able to rely on documentation to understand how the system works. Any discrepancies between documentation and actual behaviour can lead to astonishment.
- The Principle of Least Astonishment aims to enhance user experience by creating systems that are more predictable and less likely to cause confusion or frustration. By aligning design choices with users' expectations and established conventions, developers can create more user-friendly and intuitive software.
D.9. Law of Demeter
he Law of Demeter, also known as the Principle of Least Knowledge, is a design guideline in object-oriented programming that promotes loose coupling between objects. The fundamental idea behind the Law of Demeter is to minimise the dependencies between the components of a system, reducing the likelihood of unintended side effects and making the system more maintainable.
The Law of Demeter is often stated in various ways, and one common formulation is:
"Only talk to your immediate friends."
In more practical terms, this guideline can be expressed through the following principles:
Each unit should have only limited knowledge about other units:
Objects should interact with their immediate neighbours and not have extensive knowledge about the internal workings of other objects.
Each unit should only talk to its friends:
An object should communicate with its own components or objects closely associated with it, rather than reaching out to distant objects.
Don't talk to strangers:
Objects should avoid invoking methods on objects returned by other methods. Instead, they should interact with objects they already have references to.
Here's an example to illustrate the Law of Demeter:
import Foundation
// Car class
class Car {
func start() {
print("Car started")
}
}
// Person class
class Person {
var car: Car
init(car: Car) {
self.car = car
}
func driveCar() {
car.start()
}
}
// Usage
let car = Car()
let person = Person(car: car)
person.driveCar()
In this Swift code:
• We have a Car class with a start() method.
• We also have a Person class, which has a reference to a Car object.
• The Person class has a method driveCar(), which invokes the start() method on the associated Car object.
This example adheres to the Law of Demeter by allowing the Person class to interact with its immediate friend (Car) through a well-defined interface (driveCar()). The Person object doesn't directly call car.start() but rather encapsulates the interaction within its own methods.
This design promotes loose coupling between the Person and Car classes, making it easier to maintain and modify each class independently without affecting the other.
D.10. SOLID [SRP, OCP, LSP, ISP, DIP]
The SOLID principles are a set of five design principles that aim to guide software developers in creating more maintainable and scalable software systems.
The SOLID acronym represents the following principles:
Single Responsibility Principle (SRP):
A class should have only one reason to change, meaning that it should have only one responsibility.
This principle encourages developers to design classes with a single, welldefined responsibility. If a class has multiple reasons to change, it becomes harder to maintain and understand.
import Foundation
// Violation of SRP: One class with multiple responsibilities
class ReportGenerator {
func generateReport(data: [String]) {
// Generate report logic
// ...
saveReportToFile(data)
sendReportByEmail(data)
}
func saveReportToFile(_ data: [String]) {
// Save report to file logic
// ...
print("Report saved to file.")
}
func sendReportByEmail(_ data: [String]) {
// Send report by email logic
// ...
print("Report sent by email.")
}
}
The Single Responsibility Principle (SRP) suggests that a class should have only one reason to change, meaning it should have only one responsibility.
Here's a simple Swift example illustrating SRP:
// Adhering to SRP: Separate classes with distinct responsibilities
// Report generation logic
class ReportGenerator {
func generateReport(data: [String]) {
// Generate report logic
// ...
}
}
// FileSaver class responsible for saving reports to a file
class FileSaver {
func saveToFile(data: [String]) {
// Save report to file logic
// ...
print("Report saved to file.")
}
}
// EmailSender class responsible for sending reports by email
class EmailSender {
func sendByEmail(data: [String]) {
// Send report by email logic
// ...
print("Report sent by email.")
}
}
// Usage
let reportGenerator = ReportGenerator()
let fileSaver = FileSaver()
let emailSender = EmailSender()
let reportData = ["Report content"]
reportGenerator.generateReport(data: reportData)
fileSaver.saveToFile(data: reportData)
emailSender.sendByEmail(data: reportData)
Open/Closed Principle (OCP):
Software entities (classes, modules, functions) should be open for extension but closed for modification. The goal is to allow for adding new functionality without altering existing code.
This is typically achieved through the use of interfaces, abstract classes, and polymorphism.
Here's a simple Swift example illustrating the Open/Closed Principle:
Class Rectangle {
private let width: Double;
private let height: Double;
init(width: Double, height: Double) {
self.width = width
self.height = height
}
func area() -> Double {
return width * height
}
}
class Circle {
private let radius: Double;
init(width: radius) {
self.radius = radius
}
func area() -> Double {
return width * height
}
}
class ShapeProcessor {
var rectagle: Rectangle?
var circle: Circle?
func calculateTotalArea() {
var totalArea = rectagle?.area ?? 0
totalArea += circle?.area ?? 0
return totalArea
}
}
In this example
The function ShapeProcessor.calculateTotalArea() calculates the total area used by the shapes(rectagle & circle)
What if we need to add more shapes like Triangle ?
For that we have to modify the ** ShapeProcessor** class to support triangle which is violation of Open/Close principle
Here is the solution
The refactored solution adheres to the Open/Closed Principle by introducing a Shape protocol. The new shape (Triangle) adheres to this protocol, allowing for extension without modifying existing code in ** ShapeProcessor**.
`protocol Shape {
func area() -> Double
}
class Rectangle: Shape {
private let width: Double;
private let height: Double;
init(width: Double, height: Double) {
self.width = width
self.height = height
}
func area() -> Double {
return width * height
}
}
class Circle: Shape {
private let radius: Double;
init(width: radius) {
self.radius = radius
}
func area() -> Double {
return width * height
}
}
class ShapeProcessor {
var shapes: [Shape]
func calculateTotalArea() {
var totalArea: Double = 0
for shape in shapes {
totalArea += shape.area()
}
return totalArea
}
}`
Liskov Substitution Principle (LSP):
Subtypes must be substitutable for their base types without altering the correctness of the program.
This principle ensures that derived classes can be used interchangeably with their base classes without introducing errors. It is fundamental to the concept of polymorphism.
The Liskov Substitution Principle (LSP) is one of the SOLID principles and states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In Swift, this means that a subclass should extend the behaviour of its superclass without
changing its expected functionality.
Here's an example in Swift:
// Example violating Liskov Substitution Principle
class Bird {
func fly() {
print("Flying...")
}
}
class Penguin: Bird {
override func fly() {
// Violation of LSP: Penguins cannot fly
print("I can't fly!")
}
}
func makeBirdFly(bird: Bird) {
bird.fly()
}
// Client code
let bird = Bird()
let penguin = Penguin()
makeBirdFly(bird: bird) // Output: Flying...
makeBirdFly(bird: penguin) // Output: I can't fly!
In this example, we have a Bird class with a fly method. We then have a Penguin class that inherits from Bird but overrides the fly method to print that penguins can't fly. The makeBirdFly function takes a Bird parameter and calls the fly method on it.
The problem here is that when we pass a Penguin object to makeBirdFly, it violates the Liskov Substitution Principle because the behaviour of the fly method changes for Penguin. The client code expects any Bird to be able to fly, but the Penguin class modifies that behaviour.
To adhere to LSP, you should ensure that a subclass does not alter the behaviour of its superclass in a way that is unexpected.
Here's a revised example:
// Example following Liskov Substitution Principle
class Bird {
func fly() {
print("Flying...")
}
}
class Penguin: Bird {
// Penguins cannot fly, so we don't override the fly method
}
func makeBirdFly(bird: Bird) {
bird.fly()
}
// Client code
let bird = Bird()
let penguin = Penguin()
makeBirdFly(bird: bird) // Output: Flying...
makeBirdFly(bird: penguin) // Output: Flying...
Interface Segregation Principle (ISP):
A class should not be forced to implement interfaces it does not use. In other words, clients should not be forced to depend on interfaces they do not use. Instead of having large, monolithic interfaces, break them into smaller, more specific interfaces. This allows clients to depend only on the interfaces that are relevant to them.
Let's look at a Swift example to illustrate the Interface Segregation Principle:
protocol Worker {
func work()
func eat()
}
/* Manager class implementing Worker */
class Manager: Worker {
func work() {
print("Manager is working.")
}
func eat() {
print("Manager is eating.")
}
}
/* Programmer class implementing Worker */
class Programmer: Worker {
func work() {
print("Programmer is coding.")
}
func eat() {
print("Programmer is eating lunch.")
}
}
/* Violation of ISP: Robot class implementing Worker with
unnecessary method */
class Robot: Worker {
func work() {
print("Robort is working.")
}
func eat() {
/* "Robot can not eat." - Violation of ISP */
}
}
In this example, we have a Worker protocol representing individuals who can work and eat. The Manager, and Programmer classes implement the Worker interface appropriately. However, the Robot class violates the Interface Segregation Principle by implementing the unnecessary eat method, which is irrelevant to the Robot.
Here's a revised example for ISP:
/* Interface representing a human */
protocol Human {
func eat()
func sleep()
}
/* Interface representing a worker */
protocol Workable {
func work()
}
/* Manager class implementing Worker */
class Manager: Human, Workable {
func eat() {
print("Manager is eating.")
}
func sleep() {
print("Manager is sleeping.")
}
func work() {
print("Manager is working.")
}
}
/* Programmer class implementing Worker */
class Programmer: Human, Workable {
func eat() {
print("Programmer is eating lunch.")
}
func sleep() {
print("Programmer is sleeping.")
}
func work() {
print("Programmer is coding.")
}
}
class Robot: Workable {
func work() {
print("Robot is working.")
}
}
*--------------------------------------------------------------------------------------------------------------------------
*
Dependency Inversion Principle (DIP):
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
// Abstraction representing a data source
protocol DataSource {
func fetchData() -> String
}
// High-level module depending on the abstraction
class DataManager {
var dataSource: DataSource
init(dataSource: DataSource) {
self.dataSource = dataSource
}
func displayData() {
let data = dataSource.fetchData()
print("Displaying data: \(data)")
}
}
// Low-level module implementing the abstraction
class LocalDataSource: DataSource {
func fetchData() -> String {
return "Data from Local Source"
}
}
// Low-level module implementing the abstraction
class RemoteDataSource: DataSource {
func fetchData() -> String {
return "Data from Remote Source"
}
}
// Client code
let localDataSource = LocalDataSource()
let remoteDataSource = RemoteDataSource()
let localDataManager = DataManager(dataSource: localDataSource)
let remoteDataManager = DataManager(dataSource: remoteDataSource)
// Output: Displaying data: Datafrom Local Source
localDataManager.displayData()
// Output: Displaying data: Datafrom Remote Source
remoteDataManager.displayData()
- Abstractions should not depend on details; details should depend on abstractions.
// Abstraction representing a notifier
protocol Notifier {
func sendNotification(message: String)
}
// Concrete implementation depending on the abstraction
class EmailNotifier: Notifier {
func sendNotification(message: String) {
print("Sending email notification: \(message)")
}
}
class SMSNotifier: Notifier {
func sendNotification(message: String) {
print("Sending SMS notification: \(message)")
}
}
// High-level module depending on the abstraction
class NotificationService {
var notifier: Notifier
init(notifier: Notifier) {
self.notifier = notifier
}
func notifyUser(message: String) {
notifier.sendNotification(message: message)
}
}
// Client code
let emailNotifier = EmailNotifier()
let smsNotifier = SMSNotifier()
let emailNotificationService = NotificationService(notifier:
emailNotifier)
let smsNotificationService = NotificationService(notifier:
smsNotifier)
emailNotificationService.notifyUser(message: "You have a new
email.")
smsNotificationService.notifyUser(message: "You have a new SMS.")
This principle advocates the use of abstractions (e.g., interfaces or abstract classes) to decouple high-level and low-level modules. It promotes dependency injection and helps create more flexible and maintainable systems.
These SOLID principles were introduced by Robert C. Martin and have become fundamental guidelines for object-oriented design. By adhering to these principles, developers can create software that is more modular, flexible, and easier to maintain and extend over time.
Posted on December 26, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.