Design Principles

Why Design Principles Matter

Design principles guide architectural decisions to create maintainable, testable, and flexible code. Following SOLID, DRY, KISS, and YAGNI principles prevents technical debt, reduces bug density, and makes codebases easier to modify as requirements evolve.

Core Benefits:

  • Maintainability: Code organized by single responsibilities is easier to understand and modify
  • Testability: Loosely coupled components can be tested in isolation
  • Flexibility: Well-designed systems adapt to changing requirements
  • Team productivity: Consistent principles reduce cognitive load
  • Reduced bugs: Clear boundaries prevent unintended side effects

Problem: Without design principles, codebases become tangled, fragile, and resistant to change.

Solution: Apply SOLID, DRY, KISS, and YAGNI principles from the start to build systems that scale with complexity.

Single Responsibility Principle (SRP)

Each class or module should have one reason to change - one responsibility.

Violation: Multiple Responsibilities

TypeScript classes often accumulate responsibilities as features are added.

Anti-pattern:

class UserService {
  // => UserService handles TOO MANY responsibilities
  // => Violates Single Responsibility Principle

  createUser(email: string, password: string): User {
    // => Responsibility 1: User creation logic
    const user = new User(email, password);
    // => Create user object

    this.validateEmail(email);
    // => Responsibility 2: Validation logic
    // => Should be separate validator

    const hashedPassword = this.hashPassword(password);
    // => Responsibility 3: Cryptography
    // => Should be separate hasher

    this.saveToDatabase(user);
    // => Responsibility 4: Database persistence
    // => Should be separate repository

    this.sendWelcomeEmail(user);
    // => Responsibility 5: Email sending
    // => Should be separate email service

    return user;
    // => Returns created user
  }

  private validateEmail(email: string): void {
    // => Email validation logic
    // => Mixed with user creation
  }

  private hashPassword(password: string): string {
    // => Password hashing logic
    // => Mixed with user creation
  }

  private saveToDatabase(user: User): void {
    // => Database logic
    // => Mixed with user creation
  }

  private sendWelcomeEmail(user: User): void {
    // => Email logic
    // => Mixed with user creation
  }
}

Problems:

  • Changes to email validation affect user creation
  • Cannot test user creation without database
  • Cannot reuse validation in other contexts
  • Every change risks breaking multiple features

Applying SRP: Separate Responsibilities

Split into single-purpose classes.

Pattern:

class EmailValidator {
  // => Single responsibility: Email validation
  // => Can be reused across application

  validate(email: string): void {
    // => Validates email format
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    // => Basic email regex
    // => Production: Use library like validator.js

    if (!emailRegex.test(email)) {
      // => Check format
      throw new Error("Invalid email format");
      // => Throw validation error
    }
  }
}

class PasswordHasher {
  // => Single responsibility: Password hashing
  // => Encapsulates cryptography

  async hash(password: string): Promise<string> {
    // => Hash password for storage
    // => Returns bcrypt hash
    return await bcrypt.hash(password, 10);
    // => 10 salt rounds
    // => Production-grade hashing
  }
}

class UserRepository {
  // => Single responsibility: User persistence
  // => Database operations only

  async save(user: User): Promise<void> {
    // => Save user to database
    await database.users.insert({
      // => Database insert operation
      email: user.email,
      passwordHash: user.passwordHash,
    });
  }
}

class EmailService {
  // => Single responsibility: Email sending
  // => External communication

  async sendWelcomeEmail(user: User): Promise<void> {
    // => Send welcome email to new user
    await mailTransport.send({
      // => Email sending operation
      to: user.email,
      subject: "Welcome!",
      body: "Thanks for signing up",
    });
  }
}

class UserService {
  // => Orchestrates user creation
  // => Coordinates other services
  // => Single responsibility: User creation workflow

  constructor(
    private validator: EmailValidator,
    // => Dependency: Email validation
    private hasher: PasswordHasher,
    // => Dependency: Password hashing
    private repository: UserRepository,
    // => Dependency: User persistence
    private emailService: EmailService,
    // => Dependency: Email sending
  ) {}

  async createUser(email: string, password: string): Promise<User> {
    // => Orchestrate user creation
    // => Delegates to specialized services

    this.validator.validate(email);
    // => Validate email (delegated)
    // => Single responsibility maintained

    const hashedPassword = await this.hasher.hash(password);
    // => Hash password (delegated)

    const user = new User(email, hashedPassword);
    // => Create user object
    // => Core responsibility

    await this.repository.save(user);
    // => Persist user (delegated)

    await this.emailService.sendWelcomeEmail(user);
    // => Send email (delegated)

    return user;
    // => Return created user
  }
}

Benefits:

  • Each class has one reason to change
  • Can test validation without database
  • Can reuse hasher in password reset
  • Changes isolated to single class

Open/Closed Principle (OCP)

Classes should be open for extension but closed for modification.

Violation: Modifying Existing Code

Adding new features by modifying existing code breaks OCP.

Anti-pattern:

class PaymentProcessor {
  // => Processes payments
  // => Violates OCP: Must modify for new payment methods

  processPayment(amount: number, method: string): void {
    // => Payment processing logic
    // => method parameter as string (weak typing)

    if (method === "credit-card") {
      // => Credit card processing
      console.log(`Processing $${amount} via credit card`);
      // => Implementation hardcoded
    } else if (method === "paypal") {
      // => PayPal processing
      // => Added later, modified class
      console.log(`Processing $${amount} via PayPal`);
    } else if (method === "bitcoin") {
      // => Bitcoin processing
      // => Added later, modified class AGAIN
      console.log(`Processing $${amount} via Bitcoin`);
    }
    // => Every new payment method requires modification
    // => Violates Open/Closed Principle
  }
}

Problems:

  • Every new payment method modifies existing code
  • Risks breaking existing payment methods
  • Cannot add payment methods without source code access

Applying OCP: Extension via Polymorphism

Use interfaces and polymorphism to extend behavior.

Pattern:

interface PaymentMethod {
  // => Payment method interface
  // => Defines contract for all payment methods
  process(amount: number): void;
  // => All payment methods must implement process()
}

class CreditCardPayment implements PaymentMethod {
  // => Credit card implementation
  // => Implements PaymentMethod interface

  process(amount: number): void {
    // => Process credit card payment
    console.log(`Processing $${amount} via credit card`);
    // => Credit card specific logic
    // => Stripe API call in production
  }
}

class PayPalPayment implements PaymentMethod {
  // => PayPal implementation
  // => NEW class, no modification to existing code

  process(amount: number): void {
    // => Process PayPal payment
    console.log(`Processing $${amount} via PayPal`);
    // => PayPal specific logic
    // => PayPal SDK call in production
  }
}

class BitcoinPayment implements PaymentMethod {
  // => Bitcoin implementation
  // => NEW class, no modification to existing code

  process(amount: number): void {
    // => Process Bitcoin payment
    console.log(`Processing $${amount} via Bitcoin`);
    // => Bitcoin specific logic
    // => Blockchain API call in production
  }
}

class PaymentProcessor {
  // => Orchestrates payment processing
  // => CLOSED for modification, OPEN for extension

  processPayment(amount: number, method: PaymentMethod): void {
    // => Accept any PaymentMethod implementation
    // => No modification needed for new payment methods
    method.process(amount);
    // => Delegate to payment method
    // => Polymorphic dispatch
  }
}

// Usage
const processor = new PaymentProcessor();
// => Create payment processor once

processor.processPayment(100, new CreditCardPayment());
// => Process via credit card
// => Pass concrete implementation

processor.processPayment(200, new PayPalPayment());
// => Process via PayPal
// => Same processor, different implementation

processor.processPayment(300, new BitcoinPayment());
// => Process via Bitcoin
// => Added without modifying PaymentProcessor

Benefits:

  • Add payment methods without modifying existing code
  • Each payment method independently testable
  • No risk of breaking existing methods
  • Can add methods via plugins (no source code access needed)

DRY (Don’t Repeat Yourself)

Avoid code duplication by extracting reusable abstractions.

Violation: Code Duplication

Duplicated code leads to inconsistencies and maintenance burden.

Anti-pattern:

class UserController {
  // => User management endpoints
  // => Contains duplicated validation logic

  async createUser(req: Request, res: Response): Promise<void> {
    // => Create user endpoint
    // => POST /users

    if (!req.body.email) {
      // => Validate email presence
      res.status(400).json({ error: "Email required" });
      return;
    }

    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.email)) {
      // => Validate email format
      // => Regex duplicated in multiple places
      res.status(400).json({ error: "Invalid email" });
      return;
    }

    // ... create user logic
  }

  async updateUser(req: Request, res: Response): Promise<void> {
    // => Update user endpoint
    // => PUT /users/:id

    if (!req.body.email) {
      // => DUPLICATE: Same validation as createUser
      res.status(400).json({ error: "Email required" });
      return;
    }

    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.email)) {
      // => DUPLICATE: Same regex as createUser
      // => Maintenance nightmare (update in multiple places)
      res.status(400).json({ error: "Invalid email" });
      return;
    }

    // ... update user logic
  }
}

Problems:

  • Bug fix in one place doesn’t fix others
  • Changes require finding all duplicates
  • Inconsistent error messages

Applying DRY: Extract Reusable Functions

Extract duplicated logic into reusable functions.

Pattern:

class EmailValidator {
  // => Reusable email validation
  // => Single source of truth

  private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  // => Email validation regex
  // => Defined once, used everywhere

  static validate(email: string | undefined): void {
    // => Validate email presence and format
    // => Throws on validation failure

    if (!email) {
      // => Check presence
      throw new ValidationError("Email required");
      // => Consistent error handling
    }

    if (!this.EMAIL_REGEX.test(email)) {
      // => Check format
      // => Regex used once, not duplicated
      throw new ValidationError("Invalid email format");
      // => Consistent error message
    }
  }
}

class UserController {
  // => User management endpoints
  // => No duplicated validation logic

  async createUser(req: Request, res: Response): Promise<void> {
    // => Create user endpoint
    try {
      EmailValidator.validate(req.body.email);
      // => Reuse validation logic
      // => Single line, no duplication

      // ... create user logic
    } catch (error) {
      // => Handle validation errors
      res.status(400).json({ error: error.message });
    }
  }

  async updateUser(req: Request, res: Response): Promise<void> {
    // => Update user endpoint
    try {
      EmailValidator.validate(req.body.email);
      // => SAME validation logic
      // => Changes to validation automatically apply here

      // ... update user logic
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
  }
}

Benefits:

  • Single source of truth for validation
  • Bug fixes apply everywhere automatically
  • Consistent error messages
  • Easier to test validation logic

KISS (Keep It Simple, Stupid)

Prefer simple solutions over complex ones.

Violation: Premature Optimization

Over-engineering solutions before they’re needed.

Anti-pattern:

// Premature abstraction factory
interface DataFetcher {
  // => Abstract data fetcher
  // => Complex for simple use case
  fetch<T>(config: FetchConfig): Promise<T>;
}

interface FetchConfig {
  // => Configuration object
  // => Over-engineered for simple fetch
  url: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
  headers?: Record<string, string>;
  body?: any;
  retries?: number;
  timeout?: number;
  cache?: CacheStrategy;
}

type CacheStrategy = "no-cache" | "memory" | "disk" | "hybrid";
// => Complex caching strategies
// => Not needed yet

class DataFetcherFactory {
  // => Factory pattern
  // => Overkill for simple use case

  static create(strategy: CacheStrategy): DataFetcher {
    // => Creates fetcher based on strategy
    // => Complex when simple fetch() would work
    switch (strategy) {
      case "memory":
        return new MemoryCachedFetcher();
      case "disk":
        return new DiskCachedFetcher();
      case "hybrid":
        return new HybridCachedFetcher();
      default:
        return new SimpleFetcher();
    }
  }
}

// Usage: Complex for simple GET request
const fetcher = DataFetcherFactory.create("hybrid");
const data = await fetcher.fetch<User>({
  url: "/api/users/1",
  method: "GET",
  retries: 3,
  timeout: 5000,
  cache: "hybrid",
});

Problems:

  • Complex abstractions for simple use case
  • Hard to understand and maintain
  • Premature optimization

Applying KISS: Simple Solution

Start simple, add complexity only when needed.

Pattern:

async function getUser(id: string): Promise<User> {
  // => Simple function for simple use case
  // => No abstractions until needed

  const response = await fetch(`/api/users/${id}`);
  // => Standard fetch API
  // => Built-in, no custom abstraction

  if (!response.ok) {
    // => Basic error handling
    throw new Error(`HTTP ${response.status}`);
  }

  return await response.json();
  // => Parse JSON response
  // => Simple, clear, works
}

// Usage: Simple and clear
const user = await getUser("123");
// => One line, obvious behavior
// => No factory, no config object, no cache strategy

When to add complexity:

  • Need caching? Add it when performance problem identified
  • Need retries? Add it when reliability problem identified
  • Need timeout? Add it when timeout problem identified

Benefits:

  • Easy to understand
  • Easy to test
  • Easy to modify
  • Add complexity incrementally based on actual needs

YAGNI (You Aren’t Gonna Need It)

Don’t implement features until they’re actually needed.

Violation: Building for Future

Adding features “just in case” they’re needed later.

Anti-pattern:

class User {
  // => User entity
  // => Contains many unused features

  id: string;
  email: string;
  passwordHash: string;
  // => Core fields (actually used)

  phoneNumber?: string;
  // => "We might need phone numbers later"
  // => Not in requirements, not implemented anywhere

  address?: Address;
  // => "Users might want to save addresses"
  // => Not in requirements

  preferences?: UserPreferences;
  // => "Users might want to customize settings"
  // => Not in requirements

  socialProfiles?: SocialProfile[];
  // => "We might add social login"
  // => Not in requirements

  paymentMethods?: PaymentMethod[];
  // => "We might add payments"
  // => Not in requirements
}

interface Address {
  // => Address structure
  // => Defined but never used
  street: string;
  city: string;
  state: string;
  zip: string;
  country: string;
}

interface UserPreferences {
  // => User preferences
  // => Defined but never used
  theme: "light" | "dark";
  language: string;
  notifications: boolean;
}
// => All these interfaces add complexity
// => None provide value yet

Problems:

  • Code to maintain that provides no value
  • Database schema includes unused columns
  • Confuses developers (“Should I populate phoneNumber?”)
  • Might not match actual requirements when needed

Applying YAGNI: Build What’s Needed

Implement only what’s required now.

Pattern:

class User {
  // => User entity
  // => Contains ONLY what's needed NOW

  constructor(
    public readonly id: string,
    // => User ID (required)
    // => Readonly: Never changes after creation
    public readonly email: string,
    // => Email (required)
    // => Used for login
    public passwordHash: string,
    // => Password hash (required)
    // => Can change (password reset)
  ) {}
}

// When phone numbers ARE needed (later):
// 1. Add phoneNumber field
// 2. Add database migration
// 3. Add validation logic
// 4. Add to registration form
// => Do ALL of these together when feature needed
// => Don't do ANY of them "just in case"

Benefits:

  • Less code to maintain
  • Clearer what’s actually used
  • Faster development (no speculative features)
  • Actual requirements might differ from speculation

Liskov Substitution Principle (LSP)

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

Violation: Subtype Breaks Contract

Anti-pattern:

class Rectangle {
  // => Base class: Rectangle
  // => Width and height independently settable

  constructor(
    protected width: number,
    // => Width dimension
    protected height: number,
    // => Height dimension
  ) {}

  setWidth(width: number): void {
    // => Set width
    this.width = width;
    // => Only affects width
  }

  setHeight(height: number): void {
    // => Set height
    this.height = height;
    // => Only affects height
  }

  getArea(): number {
    // => Calculate area
    return this.width * this.height;
    // => width * height
  }
}

class Square extends Rectangle {
  // => Square inherits from Rectangle
  // => VIOLATES LSP: Square has different constraints

  setWidth(width: number): void {
    // => Set width
    // => ALSO sets height (different behavior)
    this.width = width;
    this.height = width;
    // => Square constraint: width === height
    // => Violates Rectangle contract
  }

  setHeight(height: number): void {
    // => Set height
    // => ALSO sets width (different behavior)
    this.width = height;
    this.height = height;
    // => Square constraint: width === height
    // => Violates Rectangle contract
  }
}

function processRectangle(rect: Rectangle): void {
  // => Function expects Rectangle behavior
  rect.setWidth(5);
  // => Set width to 5
  rect.setHeight(4);
  // => Set height to 4
  console.log(rect.getArea());
  // => Expects: 20 (5 * 4)
}

const square = new Square(10, 10);
processRectangle(square);
// => Passes Square (substitution)
// => Output: 16 (4 * 4) NOT 20!
// => VIOLATES LSP: Subtype changes behavior

Problems:

  • Square cannot substitute Rectangle
  • Unexpected behavior when using Square as Rectangle
  • Breaks polymorphism

Applying LSP: Correct Hierarchy

Design hierarchies that preserve contracts.

Pattern:

interface Shape {
  // => Shape interface
  // => Common contract for all shapes
  getArea(): number;
  // => All shapes calculate area
}

class Rectangle implements Shape {
  // => Rectangle implementation
  // => Independent from Square

  constructor(
    private width: number,
    private height: number,
  ) {}

  setWidth(width: number): void {
    // => Set width only
    this.width = width;
  }

  setHeight(height: number): void {
    // => Set height only
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height;
  }
}

class Square implements Shape {
  // => Square implementation
  // => Independent from Rectangle
  // => NO inheritance relationship

  constructor(private side: number) {
    // => Single side dimension
    // => Square constraint enforced in constructor
  }

  setSide(side: number): void {
    // => Set side length
    // => Different API than Rectangle (correct!)
    this.side = side;
  }

  getArea(): number {
    return this.side * this.side;
  }
}

function processShape(shape: Shape): void {
  // => Function works with Shape interface
  // => Only uses getArea() (common contract)
  console.log(`Area: ${shape.getArea()}`);
  // => Works correctly for both Rectangle and Square
}

const rect = new Rectangle(5, 4);
processShape(rect);
// => Works: 20

const square = new Square(4);
processShape(square);
// => Works: 16
// => Correct substitution: Shape contract preserved

Benefits:

  • Both Rectangle and Square correctly implement Shape
  • No surprising behavior
  • Polymorphism works correctly

Production Framework: ESLint with Design Principle Rules

Use ESLint plugins to enforce design principles automatically.

Installation:

npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-sonarjs
# => ESLint with TypeScript support
# => SonarJS plugin detects code smells

Configuration (.eslintrc.json):

{
  "parser": "@typescript-eslint/parser",
  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:sonarjs/recommended"],
  "plugins": ["@typescript-eslint", "sonarjs"],
  "rules": {
    "max-lines-per-function": ["error", 50],
    "max-depth": ["error", 3],
    "complexity": ["error", 10],
    "sonarjs/cognitive-complexity": ["error", 15],
    "sonarjs/no-duplicate-string": "error",
    "sonarjs/no-identical-functions": "error"
  }
}

Benefits:

  • Automatically enforces KISS (complexity limits)
  • Detects DRY violations (duplicate code)
  • Catches code smells early

Trade-offs and When to Use

When to Apply Design Principles

Use when:

  • Building production applications (long lifetime)
  • Working in teams (shared understanding)
  • Code will be modified frequently (flexibility needed)
  • Testing is priority (loosely coupled components)

Relax when:

  • Prototyping (speed over correctness)
  • One-off scripts (won’t be maintained)
  • Performance critical (optimization may violate principles)

Decision matrix:

PrincipleApply WhenSkip When
SRPProduction code, testable componentsSimple scripts, prototypes
OCPExtensible systems, plugin architecturesFixed requirements, one-time tools
DRYShared logic, validation rulesConfiguration, test data
KISSAll code (default)Proven complex requirement
YAGNIAll code (default)Known future requirement

Summary

Design principles guide architectural decisions for maintainable code. SOLID principles (SRP, OCP, LSP, ISP, DIP) ensure loose coupling and high cohesion. DRY eliminates duplication. KISS prevents over-engineering. YAGNI defers speculative features.

Key takeaways:

  • SRP: One responsibility per class
  • OCP: Extend via polymorphism, not modification
  • DRY: Extract reusable abstractions
  • KISS: Simple solutions first, add complexity when needed
  • YAGNI: Build what’s needed now, not what might be needed

Use ESLint plugins to enforce principles automatically in production code.

Related Resources

Last updated