Software Design
Overview
Software design connects the requirements of a system to the actual coding and debugging phases.
It is essential for both small-scale and large-scale projects.
The design process plays a critical role in managing project complexity, ensuring structural integrity, and balancing various necessary trade-offs.
Inherent Challenges
Wicked Problems: Design issues are often not fully understood until solutions start emerging, exemplified by the Tacoma Narrows Bridge disaster.
Sloppy Process: Software design typically involves trial and error, where mistakes are recognized and rectified to yield a clean and functional result.
Nondeterministic Nature: There can exist multiple valid solutions for any single design problem, which complicates decision-making.
Trade-offs in Software Design
Designers must juggle various competing objectives, including performance, simplicity, and extensibility.
Different design alternatives will cater to varying priorities, which are strongly dependent on the specific system requirements.
Limitations on design possibilities can simplify the process and prevent over-complexity.
Emergent Practice
Good design evolves over time, rather than being achieved instantaneously.
Elements like reviews, discussions, and real-world testing are instrumental in refining the design.
Flexibility in adapting and improving designs is a hallmark of effective emergent design.
Managing Complexity
The central technical imperative is to manage complexity effectively.
There exists a distinction between essential complexity (stemming from real-world scenarios) and accidental complexity (arising from poor initial design).
Segmenting larger problems into smaller, more manageable components is a foundational technique to mitigate complexity.
Design Heuristics
Minimal Complexity: Embrace simplicity to decrease cognitive load on users and developers.
Ease of Maintenance: Anticipate future code maintenance, particularly considering someone else may need to manage your code.
Loose Coupling: Maintain minimal interdependencies among components to enhance modularity and flexibility.
Typical Software Application Architecture
Presentation Layer: Represents the user interface.
Business Logic Layer: Encompasses the core logic of the application.
Data Access Layer: Interfaces with databases to retrieve or store data.
Database Layer: Persistently stores data across sessions.
(With an optional emphasis on flexibility based on platform or application specifics.)
Types of Architecture
Monolithic Architecture: The application is structured as a cohesive single unit where all layers (presentation, logic, data access) are intertwined in a single codebase, facilitating simpler initial development but causing increased scalability and maintenance challenges over time.
Microservices Architecture: The application is decomposed into a collection of loosely coupled services, each serving a single business goal. This distributes the complexity and improves scalability and maintainability.
Experience in Architecture
Students may find themselves utilizing microservices architecture in a professional environment, or employing 3-Tier Architecture in academically structured projects, where typical setups might include UI (HTML/React), Node.JS for business logic, and SQL databases for data management.
Characteristics of Good Software
Reusability: Code should be written such that it can be reused across other systems effectively.
Extensibility: Modifications in one segment of the application should not adversely affect other parts of the system.
High Fan-In: Aim to maximize the number of classes that utilize utility classes for efficiency.
Portability: The design should ensure that the application can be easily adapted or transferred to different platforms.
Architectural Organization Levels
System Level: Structure the entire system into subsystems or distinct components.
Class Level: Breakdown subsystems into relevant classes, allowing for clear functionality delegation.
Routine Level: Focus on the design of individual methods or routines within classes to enhance modularity.
Core Tenets of Design
Coupling: Indicates the degree of interdependency between modules. Low coupling is favorable as it promotes flexibility and ease of maintenance.
Cohesion: Represents how closely related and focused the responsibilities of a single module are. A higher degree of cohesion signifies a module that effectively performs a single task or a cohesive set of tasks.
Good Design Principles
Achieving a successful software design necessitates balancing various trade-offs while controlling complexity.
Design should evolve autonomously over time based on new insights or information gleaned.
Effectively managing complexity is a critical element in developing maintainable and efficient software solutions.
Challenges in Code Design
Example Analyses reveal problematic code structures, especially high coupling and low cohesion issues with the following function:
function fetchDataAndProcess(url) {
fetch(url)
.then(response => response.json())
.then(data => {
// Process data inside the same function
data.forEach(item => console.log(item.value * 2));
})
.catch(error => console.error('Error:', error));
}
This design is challenging to maintain and properly reuse; it performs dual responsibilities: fetching and processing data.
Implications of Poor Function Design
The mention of the function's duality results in the adversities of reusability and testability; should the need arise to fetch data without processing (or vice versa), there will be a requirement to rewrite or refactor.
Testing both tasks simultaneously complicates unit testing due to the intertwining of responsibilities.
Effective isolation and testing can become significantly difficult because testing one part necessitates invoking the other.
Better Function Structure
Constructing a function that adheres to high cohesion and low coupling can be demonstrated as follows:
function fetchData(url) {
return fetch(url)
.then(response => response.json())
.catch(error => console.error('Error:', error));
}
function processData(data) {
return data.map(item => item.value * 2);
}
function main(url) {
fetchData(url).then(data => {
const result = processData(data);
console.log(result);
});
}
This approach allows for good separation of concerns and promotes a clearer structure for each function's responsibilities.
Coupling vs. Dependency Inversion
Illustrating a case of high coupling:
function fetchUserData() {
return { id: 1, name: "John" };
}
function getUserDetails() {
const user = fetchUserData();
// Directly dependent on fetchUserData.
console.log(user.name);
}
A better approach would involve dependency injection:
function getUserDetails(fetchFn) {
const user = fetchFn();
console.log(user.name);
}
getUserDetails(fetchUserData);
Cohesion Practices
Showing the flaws in a function that has low cohesion by performing multiple tasks:
function fetchAndProcessUserData(userId) {
const user = { id: userId, name: "John" };
console.log(user.name + " (" + user.id + ")");
console.log("Logging extra info: Operation complete.");
}
Instead, via a high cohesion layout:
function fetchUserData(userId) {
return { id: userId, name: "John" };
}
function processUserData(user) {
console.log(user.name + " (" + user.id + ")");
}
function logCompletion() {
console.log("Logging extra info: Operation complete.");
}
const user = fetchUserData(1);
processUserData(user);
Dependency Management Lessons
Hardcoding dependencies leads to problematic design. Passing parameters between functions can improve information flow and allows for the effortless swapping of implementations without necessitating code rewrites.
SOLID Principles Explained
Single Responsibility Principle (SRP): Each class or function should only have a single reason to change, thus having one responsibility which enhances maintainability and comprehendibility.
Example: Prefer a single function for data fetching or processing over combining both tasks.
Open/Closed Principle (OCP): Classes should be open for extension but closed for modification, allowing for the addition of new features without altering existing code, and reducing bug risks.
Example: One should add functionality by utilizing new modules rather than modifying existing ones.
Liskov Substitution Principle (LSP): Subclasses must be workable replacements for their base classes without affecting system integrity, ensuring derived classes extend functionalities accordingly.
Example: A subclass of a base Bird class should function properly and not disrupt existing behavior.
Interface Segregation Principle (ISP): Clients should not be coerced to rely on interfaces they do not utilize. Instead, large interfaces should be subdivided into smaller, specific ones.
This enhances cohesion by restricting clients to only know necessary methods and minimizing overhead.
Dependency Inversion Principle (DIP): High-level modules should not depend directly on low-level modules but rather on abstractions, and details should defer to abstractions.
This principle dramatically reduces coupling between system components and supports more robust designs.
DRY (Don't Repeat Yourself) Principle
Definition: Asserts there should be only one unequivocal representation for every segment of knowledge or logic in the system; the objective is to eliminate redundancy across code, data, and logic to facilitate easier maintenance.
Why It Matters:
Promotes easier maintenance since altering one instance reflects on all instances, hence fewer bugs.
Enhances the readability of code, presenting more concise logic free from repetitive clutter.
Reduces the overall complexity of codebases significantly.
DRY in Practice
Refactor Repeated Logic: Identify repetitive patterns and consolidate them into functions, classes, or modules.
Example: Create a unified
validateUser()function to handle user validation instead of redundant code across various places.
Use Variables and Constants: Prefer variables/constants over hard-coded multiple instances.
Example: Define constants like
const TAXRATE = 0.20;to manage rates easily and efficiently.
Refactoring Examples
Before Refactoring (Repeated logic for area calculation)
javascript const area1 = 5 * 10; // Rectangle 1 (width * height) const area2 = 7 * 3; // Rectangle 2 (width * height)After Refactoring
function calculateArea(width, height) { return width * height; } const area1 = calculateArea(5, 10); const area2 = calculateArea(7, 3);Before Refactoring (Hardcoded taxation logic)
javascript const priceWithTax1 = 100 * 1.2; const priceWithTax2 = 200 * 1.2;After Refactoring
const TAX_RATE = 0.20; const priceWithTax1 = 100 * (1 + TAX_RATE); const priceWithTax2 = 200 * (1 + TAX_RATE);Before Refactoring (Repeated formatting logic)
javascript const price1 = '$' + 100; const price2 = '$' + 200;After Refactoring
javascript function formatPrice(amount) { return '$' + amount; } const price1 = formatPrice(100); const price2 = formatPrice(200);
Good Coding Practices
Good coding necessitates considering solutions seriously, utilizing tools like whiteboarding, flowcharts, and UML for structured thinking.
It is important to resist the impulse to dive directly into coding, even when dealing with seemingly straightforward problems, as elegant solutions may emerge when managed thoughtfully.
Ethical Considerations in Software Development
A significant realization is the potential for one's code to impact others, highlighted by future applications in critical areas like self-driving cars.
This acknowledges the joint responsibility placed on developers to ensure quality and safety in their coding practices so that their work can confidently support vital systems in modern contexts.