Code Design: Chapter 1 - Design Principles

Mehdi JaiMehdi Jai
5 min read

Introduction

Design principles are the fundamental rules that guide developers in writing clear, flexible, and maintainable code. These principles help prevent complexity, reduce errors, and make code easier to understand and adapt. Here are some of the most widely-used design principles:

SOLID

The SOLID principles are among the most influential design principles, each addressing specific aspects of good software design. Here’s an in-depth look at these principles along with TypeScript examples.

Single Responsibility Principle (SRP)

A class or module should only have one reason to change, meaning it should only have one job or responsibility. This helps keep classes focused and avoids unexpected side effects.

// Before SRP
class UserService {
  createUser(data: UserData) {
    // Logic to create a user
  }
  sendWelcomeEmail(user: User) {
    // Logic to send a welcome email
  }
}

// After SRP - Separate responsibilities
class UserCreator {
  createUser(data: UserData) {
    // Logic to create a user
  }
}

class EmailService {
  sendWelcomeEmail(user: User) {
    // Logic to send a welcome email
  }
}

In the initial version, UserService handles both user creation and email sending, violating SRP. By separating them, each class has a single responsibility, making it easier to maintain and test.

Open-Closed Principle (OCP)

Classes or modules should be open for extension but closed for modification. This means adding new functionality should not require changing existing code, reducing the risk of breaking existing features.

// Before OCP
class DiscountService {
  calculateDiscount(type: string, amount: number) {
    if (type === 'regular') return amount * 0.9;
    if (type === 'vip') return amount * 0.8;
    return amount;
  }
}

// After OCP - Extend without modifying
interface Discount {
  apply(amount: number): number;
}

class RegularDiscount implements Discount {
  apply(amount: number): number {
    return amount * 0.9;
  }
}

class VIPDiscount implements Discount {
  apply(amount: number): number {
    return amount * 0.8;
  }
}

class DiscountService {
  applyDiscount(discount: Discount, amount: number): number {
    return discount.apply(amount);
  }
}

In the initial version, adding a new discount type requires modifying DiscountService. By using interfaces, we can add new discount types without changing the DiscountService code.

Liskov Substitution Principle (LSP)

Objects of a subclass should be replaceable with objects of the parent class without affecting the correctness of the program.

// Before LSP
class Rectangle {
  constructor(public width: number, public height: number) {}
  area(): number {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(side: number) {
    super(side, side);
  }
}

// Problem: Changing width or height independently breaks LSP

// After LSP - Use separate classes for different shapes
interface Shape {
  area(): number;
}

class Rectangle implements Shape {
  constructor(public width: number, public height: number) {}
  area(): number {
    return this.width * this.height;
  }
}

class Square implements Shape {
  constructor(public side: number) {}
  area(): number {
    return this.side * this.side;
  }
}

In the initial design, Square inherits from Rectangle, but changing one dimension breaks its behavior as a square. By separating Rectangle and Square, we adhere to LSP.

Interface Segregation Principle (ISP)

Clients should not be forced to implement interfaces they do not use. It’s better to have many small, specific interfaces than a large, general-purpose one.

// Before ISP
interface Worker {
  work(): void;
  eat(): void;
}

class Developer implements Worker {
  work() { console.log("Coding"); }
  eat() { console.log("Eating"); }
}

class Robot implements Worker {
  work() { console.log("Building"); }
  eat() { /* Robot does not eat, but forced to implement */ }
}

// After ISP - Segregate interfaces
interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

class Developer implements Workable, Eatable {
  work() { console.log("Coding"); }
  eat() { console.log("Eating"); }
}

class Robot implements Workable {
  work() { console.log("Building"); }
}

In the initial version, Robot is forced to implement eat(), even though it doesn’t make sense. By segregating interfaces, each class implements only what it needs.

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules but rather on abstractions. This reduces tight coupling and makes the code more flexible.

// Before DIP
class Database {
  query(sql: string): any {
    // Database query implementation
  }
}

class UserService {
  private db = new Database();
  getUser() {
    return this.db.query("SELECT * FROM users");
  }
}

// After DIP - Depend on abstractions
interface Database {
  query(sql: string): any;
}

class SQLDatabase implements Database {
  query(sql: string): any {
    // SQL query implementation
  }
}

class UserService {
  constructor(private db: Database) {}
  getUser() {
    return this.db.query("SELECT * FROM users");
  }
}

In the improved design, UserService depends on the Database interface, not the concrete SQLDatabase. This allows for easier swapping of database implementations.

DRY (Don’t Repeat Yourself)

Avoid duplicating code to reduce errors and simplify updates. When functionality changes, you only need to update it in one place.

// Before DRY
function calculateAreaRectangle(width: number, height: number): number {
  return width * height;
}
function calculateAreaSquare(side: number): number {
  return side * side;
}

// After DRY - Reuse calculateArea
function calculateArea(dimension: { width: number, height?: number }): number {
  return shape.height ? shape.width * shape.height : shape.width * shape.width;
}

KISS (Keep It Simple, Stupid)

Keep code and systems simple, clear, and straightforward, avoiding unnecessary complexity.

Favor simple logic over overly complex solutions or abstractions that aren’t needed immediately.

YAGNI (You Aren’t Gonna Need It)

Only implement features when they’re necessary, avoiding feature bloat and excess complexity.

Avoid adding future-use methods or classes until there’s a clear need.

Summary

Design principles like SOLID, DRY, KISS, and YAGNI keep code clean, maintainable, and adaptable to change. By following these principles, developers can create systems that are easier to understand, extend, and scale. As we continue, we’ll explore how these principles integrate with development methodologies, architectural patterns, and coding standards to create robust software.


Series Chapters

  1. Introduction

  2. Chapter 1 - Design Principles

  3. Chapter 2 - Development Methodologies

  4. Chapter 3 - Architectural Patterns

  5. Chapter 4 - Coding Standards and Best Practices

0
Subscribe to my newsletter

Read articles from Mehdi Jai directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mehdi Jai
Mehdi Jai

Result-oriented Lead Full-Stack Web Developer & UI/UX designer with 5+ years of experience in designing, developing and deploying web applications.