The Blueprint for Great Software: Understanding Design Principles

The Blueprint for Great Software: Understanding Design Principles

In the ever-evolving landscape of technology, software development stands as an art form and a science, where creativity and engineering merge to shape the digital world. At the heart of this intricate craft lies the fundamental concept of software design. Software design principles serve as the guiding light for developers, helping them navigate the complexities of code, architecture, and user experience. Whether you're a seasoned programmer or just dipping your toes into the world of software development, understanding these principles is akin to unlocking the secrets to creating elegant, efficient, and maintainable software solutions. In this article, we embark on a journey to demystify the principles of software design, shedding light on the essential guidelines that underpin the creation of robust and scalable software applications. So, let's dive in and discover how these principles can transform your code from mere instructions into a masterpiece of functionality and user delight.

Table of contents:

  1. DRY
  2. KISS
  3. YAGNI
  4. SOLID


Dry:

"DRY," which stands for "Don't Repeat Yourself," is a fundamental principle in software development that emphasizes the importance of reusing code and avoiding redundancy. The goal is to write clean, maintainable, and efficient code by eliminating duplicated logic.

Scenario: Calculating Area of Various Shapes:

Imagine we're developing a program to calculate the area of different geometric shapes like rectangles, squares, and circles. We want to adhere to the DRY principle to keep our code clean and efficient.

Without DRY Principle:

Here's an example without adhering to DRY:

// Calculating the area of a rectangle
function calculateRectangleArea(length, width) {
  return length * width;
}

// Calculating the area of a square
function calculateSquareArea(sideLength) {
  return sideLength * sideLength;
}

// Calculating the area of a circle
function calculateCircleArea(radius) {
  return 3.14 * radius * radius; // Assuming π ≈ 3.14
}        

In this code, we have three separate functions, each calculating the area of a different shape. The logic for multiplication and constants is repeated, violating the DRY principle.

Applying DRY Principle:

Now, let's refactor the code to adhere to the DRY principle

// Calculating the area of various shapes
function calculateArea(shape, ...args) {
  switch (shape) {
    case 'rectangle':
      const [length, width] = args;
      return length * width;
    case 'square':
      const [sideLength] = args;
      return sideLength * sideLength;
    case 'circle':
      const [radius] = args;
      return 3.14 * radius * radius; // Assuming π ≈ 3.14
    default:
      return null; // Handle unsupported shapes gracefully
  }
}        

In this refactored code:

  • We have a single calculateArea function that accepts the shape as the first argument and a variable number of additional arguments using the rest parameter (...args).
  • Based on the shape specified, we perform the corresponding area calculation.
  • We've eliminated code duplication by reusing the multiplication logic and constants for π.

Scenario: User Authentication

function loginUserWithEmail(email, password) {
  // Code to authenticate with email and password
  // Duplicate code for email validation, database query, etc.
  // ...
}

function loginUserWithUsername(username, password) {
  // Code to authenticate with username and password
  // Duplicate code for username validation, database query, etc.
  // ...
}        
function loginUser(identifier, password) {
  // Common code for user authentication
  // Validate identifier (email or username), query the database, etc.
  // ...
}

// Usage examples
const emailLoginResult = loginUser('user@example.com', 'password123');
const usernameLoginResult = loginUser('myUsername', 'password456');        

By adhering to the DRY principle, we've made our code more maintainable and extensible. If we want to add more shapes or modify the calculation logic, we only need to make changes in one place.


KISS:

"KISS" is an acronym that stands for "Keep It Simple, Stupid." It's a fundamental principle in software development that encourages developers to keep their solutions as simple as possible while still meeting the project's requirements. The idea behind KISS is to avoid unnecessary complexity in code, design, and architecture, as simplicity tends to lead to more maintainable, understandable, and less error-pronLet's dive deeper into the KISS principle with code examples in JavaScript.

Scenario: Implementing a Basic Calculator

Consider a simple JavaScript program for a basic calculator that can perform addition and subtraction. We'll implement this program while adhering to the KISS principle.

Without Adhering to KISS:

function add(x, y) {
  // Complex addition logic with error checking
  if (typeof x !== 'number' || typeof y !== 'number') {
    throw new Error('Both operands must be numbers.');
  }
  return x + y;
}

function subtract(x, y) {
  // Complex subtraction logic with error checking
  if (typeof x !== 'number' || typeof y !== 'number') {
    throw new Error('Both operands must be numbers.');
  }
  return x - y;
}        

In this code, we have separate functions for addition and subtraction, each containing complex logic and error checking. This doesn't adhere to the KISS principle as it unnecessarily complicates a simple task.

Adhering to KISS:

function calculate(operation, x, y) {
  switch (operation) {
    case 'add':
      return x + y;
    case 'subtract':
      return x - y;
    default:
      throw new Error('Unsupported operation.');
  }
}        

In this code, we've adhered to the KISS principle:

  • We have a single calculate function that takes the operation type ('add' or 'subtract') and two operands (x and y).
  • We use a simple switch statement to perform the specified operation.
  • Error checking is minimal, focusing on the core logic of the calculator.

By keeping the code simple and avoiding unnecessary complexity, we've made it more readable, maintainable, and easier to extend if additional operations are needed in the future.

The KISS principle reminds us that solutions should be as simple as possible but not simpler, allowing us to strike a balance between simplicity and functionality in our software projects. It encourages clean, straightforward code that is easier to understand and maintain, reducing the chances of introducing bugs or making the code unnecessarily convoluted.

Scenario: Finding out if your are an adult or not

Not Adhering to KISS:

function isAdult(age) {
  if (age >= 18) {
    return true;
  } else {
    return false;
  }
}        

Adhering to KISS:

function isAdult(age) {
  return age >= 18;
}        

Scenario: Suming the numbers in an array

Not Adhering to KISS:

function sumArray(numbers) {
  let total = 0;
  for (let i = 0; i < numbers.length; i++) {
    total += numbers[i];
  }
  return total;
}
        

Adhering to KISS:


function sumArray(numbers) {
  return numbers.reduce((acc, num) => acc + num, 0);
}        

Scenario: Calculating BMI


function calculateBMI(weight, height) {
  const bmiValue = weight / (height * height);
  return bmiValue;
}        

Adhering to KISS:


function calculateBMI(weight, height) {
  return weight / (height * height);
}        

YAGNI:

"YAGNI" stands for "You Aren't Gonna Need It," and it's a software development principle that encourages developers to avoid adding functionality or code that is not currently necessary. In essence, it suggests that you should resist the temptation to over-engineer your software with features or optimizations that you anticipate needing in the future but don't currently need.

Let's explore the YAGNI principle in detail with code examples in JavaScript.

Scenario: YAGNI in JavaScript:

Imagine you're working on a simple task tracker application, and your initial requirement is to display a list of tasks with their titles and due dates. You don't need any additional features, such as task prioritization or task completion tracking, at the moment.

Without Applying YAGNI:

Without adhering to the YAGNI principle, you might start by creating a more complex task object or component that includes unnecessary features:

class Task {
  constructor(title, dueDate, priority) {
    this.title = title;
    this.dueDate = dueDate;
    this.priority = priority;
    this.completed = false;
  }

  markAsCompleted() {
    this.completed = true;
  }

  setPriority(priority) {
    this.priority = priority;
  }
}

// Usage
const task1 = new Task('Complete project', '2023-10-15', 'high');
task1.markAsCompleted();
task1.setPriority('medium');        

In this example, we've added features like marking tasks as completed and setting priorities to the Task class, which aren't currently needed for the basic task tracking requirement. This violates the YAGNI principle as it adds unnecessary complexity.

Applying YAGNI:

Now, let's apply the YAGNI principle by keeping our code simple and only implementing what is needed for the current requirements:

class Task {
  constructor(title, dueDate) {
    this.title = title;
    this.dueDate = dueDate;
  }
}

// Usage
const task1 = new Task('Complete project', '2023-10-15');        

In this YAGNI-compliant code:

  • We've simplified the Task class to only include the properties required for the current requirement, which are the task title and due date.
  • We've removed methods like markAsCompleted and setPriority as they are not currently needed.

By adhering to the YAGNI principle, we've kept our codebase simpler and easier to maintain. We've avoided unnecessary complexity and additional code that doesn't contribute to the current functionality. If and when we need additional features like task completion or priority, we can add them incrementally in response to actual requirements, rather than preemptively. This approach helps prevent code bloat and makes the codebase more agile and adaptable to changing needs.

Scenario: Avoiding overly generic functions

Without Applying YAGNI:

function filterByProperty(data, propertyName, value) {
  return data.filter(item => item[propertyName] === value);
}        

Applying YAGNI:


// If you only need to filter a specific property for now, don't create a generic function.
function filterByStatus(data, status) {
  return data.filter(item => item.status === status);
}
        

Scenario: Avoiding excessive configurations

Without Applying YAGNI:

const config = {
  apiBaseUrl: 'https://api.example.com',
  debugMode: true,
  // ... many other configuration options ...
};        

Applying YAGNI:


// Only include configuration options that are necessary at the moment.
const config = {
  apiBaseUrl: 'https://api.example.com',
};        

Scenario: Avoiding Unused dependencies

Without Applying YAGNI:

import { User, Product, Order, Payment } from 'my-library';

const user = new User();
const product = new Product();
const order = new Order();
const payment = new Payment();        

Applying YAGNI:

// Only import and use what is needed right now.
import { User } from 'my-library';

const user = new User();        

SOLID:

The SOLID principles are a set of five design principles in object-oriented programming and software design that help developers create more maintainable, flexible, and scalable code. These principles guide developers in writing code that is easier to understand, extend, and modify. In this explanation, we will dive deep into each of the SOLID principles with JavaScript code examples.

SOLID is an acronym that represents the following principles:

  • Single Responsibility Principle (SRP):A class should have only one reason to change, meaning it should have only one responsibility.This principle encourages small, focused classes.

// Without SRP
class User {
  constructor(name) {
    this.name = name;
  }

  getName() {
    return this.name;
  }

  saveToDatabase() {
    // Code for saving to the database
  }

  sendEmail() {
    // Code for sending emails
  }
}

// With SRP
class User {
  constructor(name) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

class UserRepository {
  saveToDatabase(user) {
    // Code for saving to the database
  }
}

class EmailService {
  sendEmail(user) {
    // Code for sending emails
  }
}
        

In the SRP-compliant code, we have separated the responsibilities of managing a user, saving to the database, and sending emails into distinct classes.

  • Open/Closed Principle (OCP):Software entities (classes, modules, functions) should be open for extension but closed for modification.This principle encourages you to extend behavior without altering existing code.

// Without OCP
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  calculateArea() {
    return this.width * this.height;
  }
}

class Circle {
  constructor(radius) {
    this.radius = radius;
  }

  calculateArea() {
    return Math.PI * this.radius * this.radius;
  }
}

// With OCP
class Shape {
  calculateArea() {
    throw new Error("Subclasses must implement calculateArea");
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  calculateArea() {
    return this.width * this.height;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  calculateArea() {
    return Math.PI * this.radius * this.radius;
  }
}
        

In the OCP-compliant code, we introduced an abstract Shape class that defines the calculateArea method. This allows us to add new shapes (e.g., triangles) without modifying the existing code.

// DiscountStrategy (open for extension)
class DiscountStrategy {
  applyDiscount(price) {
    throw new Error("Subclasses must implement the 'applyDiscount' method.");
  }
}

// PercentageDiscount (closed for modification, open for extension)
class PercentageDiscount extends DiscountStrategy {
  constructor(percentage) {
    super();
    this.percentage = percentage;
  }

  applyDiscount(price) {
    return price - (price * (this.percentage / 100));
  }
}

// FixedAmountDiscount (closed for modification, open for extension)
class FixedAmountDiscount extends DiscountStrategy {
  constructor(discountAmount) {
    super();
    this.discountAmount = discountAmount;
  }

  applyDiscount(price) {
    return price - this.discountAmount;
  }
}

// ShoppingCart
class ShoppingCart {
  constructor() {
    this.items = [];
  }

  addItem(item) {
    this.items.push(item);
  }

  calculateTotal(discountStrategy) {
    const total = this.items.reduce((acc, item) => acc + item.price, 0);
    return discountStrategy.applyDiscount(total);
  }
}

// Usage
const cart = new ShoppingCart();
cart.addItem({ name: "Item 1", price: 50 });
cart.addItem({ name: "Item 2", price: 30 });

const percentageDiscount = new PercentageDiscount(10); // 10% discount
const fixedAmountDiscount = new FixedAmountDiscount(15); // $15 discount

console.log("Total with 10% discount:", cart.calculateTotal(percentageDiscount)); // Outputs: Total with 10% discount: 75
console.log("Total with $15 discount:", cart.calculateTotal(fixedAmountDiscount)); // Outputs: Total with $15 discount: 65
        

In this JavaScript example:

  • The DiscountStrategy class is open for extension. It provides a basic structure for discount strategies but doesn't implement the applyDiscount method.
  • The PercentageDiscount and FixedAmountDiscount classes are closed for modification. They extend the DiscountStrategy class and implement their own applyDiscount methods to calculate discounts based on their respective strategies.
  • The ShoppingCart class takes a discount strategy as a parameter to calculate the total price based on the selected discount.

You can easily introduce new discount strategies (e.g., BuyOneGetOneFreeDiscount) by creating new classes that extend the DiscountStrategy class without modifying the existing code. This design adheres to the Open/Closed Principle, allowing you to extend the system with new discount strategies without changing the behavior of existing ones.

  • 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 (subtypes) maintain the behavior expected from their base classes.

class Bird {
  fly() {
    console.log("Bird is flying");
  }
}

class Ostrich extends Bird {
  // Ostriches cannot fly, violating LSP
  fly() {
    throw new Error("Ostrich cannot fly");
  }
}

const bird = new Ostrich(); // This violates LSP
bird.fly(); // Throws an error        

In this example, the Ostrich class violates the Liskov Substitution Principle because it overrides the fly method to throw an error, which is unexpected behavior for a bird subtype.

Scenario: Stack Data Structure

Let's consider a stack data structure where we have a base Stack class, and we want to create a derived class FixedStack that represents a stack with a fixed maximum capacity.

class Stack {
  constructor() {
    this.items = [];
  }

  push(item) {
    this.items.push(item);
  }

  pop() {
    return this.items.pop();
  }

  isEmpty() {
    return this.items.length === 0;
  }

  size() {
    return this.items.length;
  }
}

class FixedStack extends Stack {
  constructor(capacity) {
    super();
    this.capacity = capacity;
  }

  push(item) {
    if (this.size() < this.capacity) {
      super.push(item);
    } else {
      throw new Error("Stack is full");
    }
  }
}

// Usage
const stack = new FixedStack(3); // Create a stack with a capacity of 3
stack.push(1);
stack.push(2);
stack.push(3);

console.log(stack.size()); // Outputs: 3

stack.push(4); // Throws an error: Stack is full        

In this example:

  • We have a base Stack class with standard stack operations: push, pop, isEmpty, and size.
  • We create a derived class FixedStack that inherits from Stack and adds the ability to set a maximum capacity. It overrides the push method to check if the stack is full before pushing an item.
  • Despite the FixedStack class having additional constraints, it still adheres to the LSP because it can be used interchangeably with the base Stack class without breaking the correctness of the program.

  • Interface Segregation Principle (ISP):Clients should not be forced to depend on interfaces they do not use.This principle encourages smaller, specific interfaces rather than large, monolithic ones.

// Without ISP
class Worker {
  work() {
    // Some work
  }

  eat() {
    // Eating during break
  }
}

class Manager {
  delegateWork(worker) {
    worker.work();
  }

  manage() {
    // Manager-specific tasks
  }
}

// With ISP
class Worker {
  work() {
    // Some work
  }
}

class Eater {
  eat() {
    // Eating during break
  }
}

class Manager {
  delegateWork(worker) {
    worker.work();
  }

  manage() {
    // Manager-specific tasks
  }
}
        

In the ISP-compliant code, we've split the Worker class into two separate classes, Worker and Eater, to create more focused interfaces. This way, clients (like the Manager class) only depend on the interfaces they need.

Scenario: Employee Management System

Suppose we have an employee management system with different types of employees: regular employees, managers, and temporary workers. We want to follow the ISP to ensure that each type of employee only depends on the interfaces they use.

// Without ISP

// Interface for regular employees
class RegularEmployee {
  constructor(name) {
    this.name = name;
  }

  work() {
    // Perform regular work tasks
  }

  takeBreak() {
    // Take a break
  }

  reportToManager() {
    // Report to a manager
  }
}

// Interface for managers
class Manager {
  constructor(name) {
    this.name = name;
  }

  work() {
    // Perform management tasks
  }

  reportToManager() {
    // Report to a higher-level manager
  }
}

// Interface for temporary workers
class TemporaryWorker {
  constructor(name) {
    this.name = name;
  }

  work() {
    // Perform temporary work tasks
  }

  takeBreak() {
    // Take a break
  }
}

// With ISP

// Common interface for all employees
class Employee {
  constructor(name) {
    this.name = name;
  }

  work() {
    // Perform work tasks (common to all employees)
  }
}

// Interface for regular employees
class RegularEmployee extends Employee {
  reportToManager() {
    // Report to a manager
  }
}

// Interface for managers
class Manager extends Employee {
  reportToManager() {
    // Report to a higher-level manager
  }
}

// Interface for temporary workers
class TemporaryWorker extends Employee {
  takeBreak() {
    // Take a break
  }
}        

In this ISP-compliant code:

  • We have a common Employee class that defines the common behavior shared by all types of employees, such as the work method.
  • Each specific type of employee (e.g., RegularEmployee, Manager, TemporaryWorker) extends the Employee class and includes only the methods relevant to their role.
  • Regular employees and managers report to managers, so they have the reportToManager method, while temporary workers take breaks, so they have the takeBreak method.

  • Dependency Inversion Principle (DIP):High-level modules should not depend on low-level modules. Both should depend on abstractions.Abstractions should not depend on details. Details should depend on abstractions.This principle encourages the use of interfaces or abstract classes to decouple high-level and low-level components.

// Without DIP
class LightBulb {
  turnOn() {
    // Code to turn on the light
  }

  turnOff() {
    // Code to turn off the light
  }
}

class Switch {
  constructor(bulb) {
    this.bulb = bulb;
  }

  operate() {
    // Operate the light bulb
  }
}

const bulb = new LightBulb();
const switchButton = new Switch(bulb); // High-level module depends on a low-level module
switchButton.operate();

// With DIP
class Switchable {
  operate() {
    throw new Error("Subclasses must implement operate");
  }
}

class LightBulb extends Switchable {
  operate() {
    // Code to turn on/off the light
  }
}

class Fan extends Switchable {
  operate() {
    // Code to turn on/off the fan
  }
}

class Switch {
  constructor(device) {
    this.device = device;
  }

  operate() {
    this.device.operate();
  }
}

const bulb = new LightBulb();
const switchButton = new Switch(bulb); // High-level module depends on an abstraction
switchButton.operate();        

In the DIP-compliant code, we introduced an abstract Switchable class that defines the operate method. Both LightBulb and Fan are now subclasses of Switchable, and the Switch class depends on the Switchable abstraction rather than concrete implementations.

By adhering to the SOLID principles, developers can create more maintainable and flexible software systems, making it easier to add features, fix bugs, and scale the application while minimizing code fragility and complexity.

Scenario: Payment Gateway

// Dependency: PaymentGateway
class PaymentGateway {
  constructor(paymentProvider) {
    this.paymentProvider = paymentProvider;
  }

  processPayment(amount) {
    // Process payment using the specified payment provider
    return this.paymentProvider.process(amount);
  }
}

// Dependency: PayPalPaymentProvider
class PayPalPaymentProvider {
  process(amount) {
    // Implement PayPal payment processing logic
    return `Paid $${amount} via PayPal`;
  }
}

// Dependency: CreditCardPaymentProvider
class CreditCardPaymentProvider {
  process(amount) {
    // Implement credit card payment processing logic
    return `Paid $${amount} via Credit Card`;
  }
}

// ShoppingCart depends on PaymentGateway
class ShoppingCart {
  constructor(paymentGateway) {
    this.paymentGateway = paymentGateway;
    this.items = [];
  }

  addItem(item) {
    this.items.push(item);
  }

  checkout() {
    const totalPrice = this.calculateTotalPrice();
    const paymentResult = this.paymentGateway.processPayment(totalPrice);
    return `Checkout complete. ${paymentResult}`;
  }

  calculateTotalPrice() {
    // Calculate the total price of items in the cart
    // (Logic for calculating the total price)
    return 100; // For simplicity, we use a fixed price here
  }
}

// Application Setup
const payPalGateway = new PaymentGateway(new PayPalPaymentProvider());
const creditCardGateway = new PaymentGateway(new CreditCardPaymentProvider());

const cart1 = new ShoppingCart(payPalGateway);
cart1.addItem({ name: 'Item 1', price: 50 });
console.log(cart1.checkout());

const cart2 = new ShoppingCart(creditCardGateway);
cart2.addItem({ name: 'Item 2', price: 75 });
console.log(cart2.checkout());        

In this example:

  • We have a PaymentGateway class that depends on a payment provider (e.g., PayPal or Credit Card). The processPayment method delegates payment processing to the specified payment provider.
  • The ShoppingCart class depends on a PaymentGateway. When a customer checks out, it calculates the total price and uses the injected PaymentGateway to process the payment.
  • We set up the application with different payment gateways (PayPal and Credit Card) and use dependency injection to provide the appropriate gateway to each shopping cart.

This demonstrates the Dependency Injection principle by injecting dependencies (payment gateways) into the ShoppingCart class, promoting loose coupling and making it easy to switch or extend payment providers without modifying the cart logic.


#dry #yagni #kiss #solid

To view or add a comment, sign in

Others also viewed

Explore topics