Home / Notebooks / Good Software Essentials
Good Software Essentials
intermediate

Software Design Principles

Foundational principles that guide writing maintainable, flexible, and robust software

April 26, 2026
Updated regularly

Software Design Principles

Foundational principles that guide decision-making when designing and writing software. These are heuristics — not laws — so apply them with judgment.

SOLID Principles

A set of five principles that make object-oriented designs more understandable, flexible, and maintainable.

S — Single Responsibility Principle

> A class should have one reason to change.

// ❌ BAD: Multiple responsibilities
class User {
  constructor(public name: string, public email: string) {}

  save() { db.save(this); }              // persistence
  sendWelcomeEmail() { mailer.send(); }  // communication
  generateReport() { return `...`; }     // reporting
}

// ✅ GOOD: One responsibility each
class User {
  constructor(public name: string, public email: string) {}
}

class UserRepository {
  save(user: User) { db.save(user); }
}

class UserMailer {
  sendWelcome(user: User) { mailer.send(user.email, 'Welcome!'); }
}

class UserReporter {
  generate(user: User) { return `Report for ${user.name}`; }
}

O — Open/Closed Principle

> Open for extension, closed for modification.

// ❌ BAD: Must modify existing code for every new shape
class AreaCalculator {
  calculate(shape) {
    if (shape.type === 'circle') return Math.PI * shape.radius ** 2;
    if (shape.type === 'square') return shape.side ** 2;
    // Adding triangle requires editing this class
  }
}

// ✅ GOOD: Extend by adding new classes, never touching existing ones
interface Shape {
  area(): number;
}

class Circle implements Shape {
  constructor(private radius: number) {}
  area() { return Math.PI * this.radius ** 2; }
}

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

class Triangle implements Shape {  // new shape — no existing code changes
  constructor(private base: number, private height: number) {}
  area() { return 0.5 * this.base * this.height; }
}

class AreaCalculator {
  calculate(shape: Shape) { return shape.area(); }
}

L — Liskov Substitution Principle

> Subtypes must be substitutable for their base types without altering correctness.

// ❌ BAD: Square breaks the contract of Rectangle
class Rectangle {
  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
  area() { return this.width * this.height; }
}

class Square extends Rectangle {
  setWidth(w: number) {
    this.width = w;
    this.height = w; // silently changes height — breaks expectations
  }
}

function resizeAndPrint(rect: Rectangle) {
  rect.setWidth(5);
  rect.setHeight(10);
  console.log(rect.area()); // Expected: 50. Square gives: 100 ❌
}

// ✅ GOOD: Separate types, no broken contract
interface Shape {
  area(): number;
}

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

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

I — Interface Segregation Principle

> Clients should not be forced to depend on interfaces they don't use.

// ❌ BAD: Fat interface forces unnecessary implementation
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
}

class Robot implements Worker {
  work() { /* works */ }
  eat() { throw new Error('Robots do not eat'); }   // forced stub
  sleep() { throw new Error('Robots do not sleep'); } // forced stub
}

// ✅ GOOD: Focused interfaces
interface Workable { work(): void; }
interface Eatable  { eat(): void; }
interface Sleepable { sleep(): void; }

class Human implements Workable, Eatable, Sleepable {
  work() {}
  eat() {}
  sleep() {}
}

class Robot implements Workable {
  work() {} // only what it needs
}

D — Dependency Inversion Principle

> Depend on abstractions, not concretions.

// ❌ BAD: High-level module depends on a low-level concrete class
class OrderService {
  private db = new MySQLDatabase(); // hardcoded dependency

  saveOrder(order: Order) {
    this.db.insert('orders', order);
  }
}

// ✅ GOOD: Depend on an abstraction, inject the implementation
interface Database {
  insert(table: string, record: object): void;
}

class OrderService {
  constructor(private db: Database) {} // accepts any database

  saveOrder(order: Order) {
    this.db.insert('orders', order);
  }
}

// Easy to swap
const mysqlService = new OrderService(new MySQLDatabase());
const inMemoryService = new OrderService(new InMemoryDatabase()); // for tests

DRY — Don't Repeat Yourself

> Every piece of knowledge should have a single, authoritative representation.

// ❌ BAD: Validation logic duplicated in three places
function createUser(data) {
  if (!data.email || !/\S+@\S+\.\S+/.test(data.email)) throw new Error('Invalid email');
  // ...
}
function updateUser(id, data) {
  if (!data.email || !/\S+@\S+\.\S+/.test(data.email)) throw new Error('Invalid email');
  // ...
}
function inviteUser(email) {
  if (!email || !/\S+@\S+\.\S+/.test(email)) throw new Error('Invalid email');
  // ...
}

// ✅ GOOD: Single source of truth
function validateEmail(email: string) {
  if (!email || !/\S+@\S+\.\S+/.test(email)) throw new Error('Invalid email');
}

function createUser(data) { validateEmail(data.email); /* ... */ }
function updateUser(id, data) { validateEmail(data.email); /* ... */ }
function inviteUser(email) { validateEmail(email); /* ... */ }

> Note: DRY is about knowledge, not just text. Two identical-looking snippets that represent different concepts should stay separate.

KISS — Keep It Simple, Stupid

> Prefer the simplest solution that solves the problem.

// ❌ BAD: Overengineered for a simple use case
class UserNameFormatterFactory {
  createFormatter(strategy: string): UserNameFormatter {
    return FormatterRegistry.getInstance().resolve(strategy);
  }
}

// ✅ GOOD: Just write the function
function formatUserName(firstName: string, lastName: string): string {
  return `${firstName} ${lastName}`.trim();
}

Complexity signals:

  • More than two levels of abstraction for a simple operation
  • Patterns applied before there's more than one use case
  • Configuration for things that won't vary
  • YAGNI — You Aren't Gonna Need It

    > Don't build features until they are actually needed.

    // ❌ BAD: Built for a future that may never come
    class PaymentService {
      processPayment(amount, method, currency, region, taxRate, loyaltyPoints, giftCard) {
        // Handles every possible scenario "just in case"
      }
    }
    
    // ✅ GOOD: Solve what's needed now, refactor when the need is real
    class PaymentService {
      processPayment(amount: number, method: 'card' | 'paypal') {
        // Handles what we actually need today
      }
    }
    

    Law of Demeter (Principle of Least Knowledge)

    > A module should only talk to its immediate friends, not strangers.

    // ❌ BAD: Reaching through multiple levels
    class Order {
      getTotal() {
        return this.customer.wallet.balance.getAmount(); // knows too much
      }
    }
    
    // ✅ GOOD: Ask the direct collaborator
    class Order {
      getTotal() {
        return this.customer.getBalance(); // customer encapsulates the rest
      }
    }
    

    Rule of thumb: limit method chains to one dot when crossing object boundaries.

    Fail Fast

    > Detect and report errors as early as possible.

    // ❌ BAD: Accepts invalid input, fails mysteriously later
    function createOrder(userId, items) {
      const order = { userId, items };
      // items might be empty — the bug surfaces much later at payment
      processPayment(order);
    }
    
    // ✅ GOOD: Validate at the entry point
    function createOrder(userId: string, items: Item[]) {
      if (!userId) throw new Error('userId is required');
      if (!items?.length) throw new Error('Order must have at least one item');
    
      const order = { userId, items };
      processPayment(order);
    }
    

    Principle of Least Astonishment

    > A component should behave in a way that users and developers expect.

    // ❌ BAD: getUser sounds read-only but has a side effect
    function getUser(id) {
      const user = db.find(id);
      auditLog.record(`Fetched user ${id}`); // surprise side effect
      return user;
    }
    
    // ✅ GOOD: Separate concerns, no hidden surprises
    function getUser(id) {
      return db.find(id);
    }
    
    function getAndAuditUser(id) {
      const user = getUser(id);
      auditLog.record(`Fetched user ${id}`);
      return user;
    }
    

    Principles at a Glance

    PrincipleOne-Line Rule
    SRPOne class, one reason to change
    OCPExtend by adding, not modifying
    LSPSubclasses must honor the parent's contract
    ISPSmall interfaces over fat ones
    DIPDepend on abstractions, inject concretions
    DRYEvery fact has one home
    KISSChoose the simpler option
    YAGNIBuild what you need, when you need it
    LoDDon't talk to strangers
    Fail FastSurface errors early
    PoLANo surprises

    When to Break the Rules

    Principles are guidelines, not laws. Break them deliberately when:

  • Strict SRP leads to excessive indirection for trivial logic
  • DRY would create the wrong coupling between unrelated concepts
  • OCP would add complexity before it's needed (YAGNI wins)
  • The key is to know the rule well enough to know when breaking it is the right call, and to document why.

    Tips

  • Apply principles to solve a real problem, not to demonstrate you know them
  • Prefer two principles applied well over five applied mechanically
  • When in doubt, ask: "How hard would it be to change this in six months?"
  • Good design emerges from repeated small improvements, not upfront grand plans
  • Resources

  • Clean Code — Robert C. Martin
  • Clean Architecture — Robert C. Martin
  • A Philosophy of Software Design — John Ousterhout
  • SOLID Principles — oodesign.com
  • Refactoring Guru — SOLID
  • Topics

    Design PrinciplesSOLIDBest PracticesSoftware Engineering

    Found This Helpful?

    If you have questions or suggestions for improving these notes, I'd love to hear from you.