DevDocsDev Docs
Design PrinciplesGeneral Principles

KISS - Keep It Simple, Stupid

Simplicity is the ultimate sophistication - avoid unnecessary complexity

KISS - Keep It Simple, Stupid

"Simplicity is the ultimate sophistication." — Leonardo da Vinci

"Everything should be made as simple as possible, but not simpler." — Albert Einstein

KISS is about choosing the simplest solution that solves the problem. Complexity should be justified by requirements, not speculation or cleverness.


The Problem

Over-Engineering Simple Tasks

When developers anticipate future requirements or try to make code "extensible," they often create unnecessary complexity.

/**
 * ❌ BAD: Massively over-engineered solution for a simple task
 * 
 * Task: Check if a user is an adult (age >= 18)
 */

// Step 1: Create a validation strategy interface
interface ValidationStrategy<T> {
  validate: (value: T) => boolean;
  getErrorMessage: () => string;
  getSuccessMessage: () => string;
}

// Step 2: Create a validation context
interface ValidationContext<T> {
  setStrategy: (strategy: ValidationStrategy<T>) => void;
  execute: (value: T) => ValidationResult;
}

interface ValidationResult {
  isValid: boolean;
  message: string;
  timestamp: Date;
  validationType: string;
}

// Step 3: Create a validation factory
interface ValidationFactory {
  createAgeValidator: () => ValidationStrategy<number>;
  createEmailValidator: () => ValidationStrategy<string>;
  // ... more validators we might need someday
}

// Step 4: Implement the context
const createValidationContext = <T>(): ValidationContext<T> => {
  let currentStrategy: ValidationStrategy<T> | null = null;
  
  return {
    setStrategy(strategy) {
      currentStrategy = strategy;
    },
    execute(value) {
      if (!currentStrategy) {
        throw new Error("No validation strategy set");
      }
      
      const isValid = currentStrategy.validate(value);
      
      return {
        isValid,
        message: isValid 
          ? currentStrategy.getSuccessMessage()
          : currentStrategy.getErrorMessage(),
        timestamp: new Date(),
        validationType: "strategy-based",
      };
    },
  };
};

// Step 5: Implement the adult validation strategy
const createAdultValidationStrategy = (): ValidationStrategy<number> => ({
  validate: (age) => age >= 18,
  getErrorMessage: () => "User is not an adult",
  getSuccessMessage: () => "User is an adult",
});

// Step 6: Use all of this machinery
const context = createValidationContext<number>();
context.setStrategy(createAdultValidationStrategy());
const result = context.execute(25);
console.log(result.isValid); // true

// All that for... checking if age >= 18?
// 50+ lines of code for a one-liner

The Solution

Start Simple, Add Complexity When Needed

/**
 * ✅ GOOD: Start with the simplest solution
 */

// Task: Check if a user is an adult
const isAdult = (age: number): boolean => age >= 18;

// That's it! One line, perfectly clear.

// Usage is obvious
console.log(isAdult(25)); // true
console.log(isAdult(16)); // false

// Need to check for senior? Add another simple function
const isSenior = (age: number): boolean => age >= 65;

// Need configurable age check? Still simple!
const isAtLeast = (minAge: number) => (age: number): boolean => age >= minAge;

const canDrinkInUSA = isAtLeast(21);
const canRentCar = isAtLeast(25);

console.log(canDrinkInUSA(22)); // true
console.log(canRentCar(23));    // false

// Need validation with error messages? Keep it simple!
interface ValidationResult {
  valid: boolean;
  error?: string;
}

const validateAge = (age: number, minAge: number = 18): ValidationResult => {
  if (age < minAge) {
    return { valid: false, error: `Age must be at least ${minAge}` };
  }
  return { valid: true };
};

console.log(validateAge(25));     // { valid: true }
console.log(validateAge(16));     // { valid: false, error: "Age must be at least 18" }
console.log(validateAge(20, 21)); // { valid: false, error: "Age must be at least 21" }
/**
 * ✅ GOOD: Readable code beats clever code
 */

// ❌ BAD: Clever one-liners that are hard to understand
interface CartItem {
  name: string;
  price: number;
  quantity: number;
}

const items: CartItem[] = [
  { name: "A", price: 10, quantity: 2 },
  { name: "B", price: 20, quantity: 1 },
  { name: "C", price: 15, quantity: 3 },
];

// "Clever" version - what does this do?
const total1 = items.reduce((a, i) => a + i.price * i.quantity * (i.quantity > 2 ? 0.9 : 1), 0);

// ✅ GOOD: Clear, step-by-step version
const calculateTotal = (items: CartItem[]): number => {
  let total = 0;
  
  for (const item of items) {
    const itemTotal = item.price * item.quantity;
    
    // Apply 10% bulk discount for quantity > 2
    const discount = item.quantity > 2 ? 0.1 : 0;
    const discountedTotal = itemTotal * (1 - discount);
    
    total += discountedTotal;
  }
  
  return total;
};

console.log(calculateTotal(items)); // 65.5

// ❌ BAD: Clever regex nobody can read
const obscureEmailCheck = (e: string) => /^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/i.test(e);

// ✅ GOOD: Simple, good-enough email check
const isValidEmail = (email: string): boolean => {
  // Simple check: has @, has something before and after
  if (!email.includes("@")) return false;
  
  const [local, domain] = email.split("@");
  
  if (!local || !domain) return false;
  if (!domain.includes(".")) return false;
  
  return true;
};

// Even better: use a library for complex validation
// import { z } from "zod";
// const emailSchema = z.string().email();

// ❌ BAD: Overly clever nested ternary
const getStatus1 = (score: number) => 
  score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 60 ? "D" : "F";

// ✅ GOOD: Clear switch or if-else
const getGrade = (score: number): string => {
  if (score >= 90) return "A";
  if (score >= 80) return "B";
  if (score >= 70) return "C";
  if (score >= 60) return "D";
  return "F";
};

// Or with a lookup (also clear)
const GRADE_THRESHOLDS = [
  { min: 90, grade: "A" },
  { min: 80, grade: "B" },
  { min: 70, grade: "C" },
  { min: 60, grade: "D" },
  { min: 0, grade: "F" },
] as const;

const getGradeAlt = (score: number): string => {
  const match = GRADE_THRESHOLDS.find(t => score >= t.min);
  return match?.grade ?? "F";
};
/**
 * ✅ GOOD: Add complexity only when requirements demand it
 */

// STAGE 1: Initial requirement - fetch user data
// Simple implementation, no abstraction needed
const fetchUser1 = async (id: string) => {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
};

// STAGE 2: New requirement - need error handling
// Still simple, just add try/catch
const fetchUser2 = async (id: string) => {
  try {
    const response = await fetch(`/api/users/${id}`);
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    
    return await response.json();
  } catch (error) {
    console.error("Failed to fetch user:", error);
    return null;
  }
};

// STAGE 3: New requirement - need retry on failure
// Now it's worth creating a small abstraction
const fetchWithRetry = async <T>(
  url: string,
  maxRetries: number = 3
): Promise<T | null> => {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url);
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      
      return await response.json();
    } catch (error) {
      console.error(`Attempt ${attempt} failed:`, error);
      
      if (attempt === maxRetries) {
        return null;
      }
      
      // Simple exponential backoff
      await new Promise(r => setTimeout(r, 1000 * attempt));
    }
  }
  
  return null;
};

const fetchUser3 = (id: string) => 
  fetchWithRetry<{ id: string; name: string }>(`/api/users/${id}`);

// STAGE 4: New requirement - need caching
// Now we add a cache layer, still keeping it simple
interface Cache<T> {
  get: (key: string) => T | undefined;
  set: (key: string, value: T, ttlMs: number) => void;
}

const createSimpleCache = <T>(): Cache<T> => {
  const cache = new Map<string, { value: T; expiresAt: number }>();
  
  return {
    get(key) {
      const entry = cache.get(key);
      if (!entry) return undefined;
      if (Date.now() > entry.expiresAt) {
        cache.delete(key);
        return undefined;
      }
      return entry.value;
    },
    set(key, value, ttlMs) {
      cache.set(key, { value, expiresAt: Date.now() + ttlMs });
    },
  };
};

const userCache = createSimpleCache<{ id: string; name: string }>();

const fetchUser4 = async (id: string) => {
  // Check cache first
  const cached = userCache.get(id);
  if (cached) return cached;
  
  // Fetch if not cached
  const user = await fetchWithRetry<{ id: string; name: string }>(`/api/users/${id}`);
  
  if (user) {
    userCache.set(id, user, 60000); // Cache for 1 minute
  }
  
  return user;
};

// Notice: We only added complexity when REQUIRED
// Each stage is still simple and understandable
/**
 * ✅ GOOD: Real-world simplicity examples
 */

// --- Example 1: Simple state machine ---

type OrderStatus = "pending" | "paid" | "shipped" | "delivered" | "cancelled";

// ❌ COMPLEX: State machine library with guards, actions, services
// ✅ SIMPLE: Just a function with clear logic
const getNextStatuses = (current: OrderStatus): OrderStatus[] => {
  switch (current) {
    case "pending":
      return ["paid", "cancelled"];
    case "paid":
      return ["shipped", "cancelled"];
    case "shipped":
      return ["delivered"];
    case "delivered":
    case "cancelled":
      return []; // Terminal states
  }
};

const canTransition = (from: OrderStatus, to: OrderStatus): boolean => {
  return getNextStatuses(from).includes(to);
};

// --- Example 2: Simple event system ---

// ❌ COMPLEX: Full pub/sub with namespaces, wildcards, async handling
// ✅ SIMPLE: Basic event emitter
type EventHandler<T> = (data: T) => void;

const createEventEmitter = <Events extends { [key: string]: unknown }>() => {
  const handlers = new Map<keyof Events, Set<EventHandler<unknown>>>();
  
  return {
    on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>) {
      if (!handlers.has(event)) handlers.set(event, new Set());
      handlers.get(event)!.add(handler as EventHandler<unknown>);
    },
    
    off<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>) {
      handlers.get(event)?.delete(handler as EventHandler<unknown>);
    },
    
    emit<K extends keyof Events>(event: K, data: Events[K]) {
      handlers.get(event)?.forEach(h => h(data));
    },
  };
};

// Usage is dead simple
type AppEvents = {
  userLoggedIn: { userId: string };
  orderCreated: { orderId: string; total: number };
};

const events = createEventEmitter<AppEvents>();

events.on("userLoggedIn", ({ userId }) => {
  console.log(`User ${userId} logged in`);
});

events.emit("userLoggedIn", { userId: "123" });

// --- Example 3: Simple config ---

// ❌ COMPLEX: Config service with validation, watchers, encryption
// ✅ SIMPLE: Just an object with type safety
const Config = {
  api: {
    baseUrl: process.env.API_URL ?? "http://localhost:3000",
    timeout: Number(process.env.API_TIMEOUT ?? 5000),
  },
  features: {
    darkMode: process.env.FEATURE_DARK_MODE === "true",
    betaFeatures: process.env.FEATURE_BETA === "true",
  },
  limits: {
    maxUploadSize: 10 * 1024 * 1024, // 10MB
    maxItems: 100,
  },
} as const;

// Usage
console.log(Config.api.baseUrl);
console.log(Config.features.darkMode);

// --- Example 4: Simple dependency injection ---

// ❌ COMPLEX: IoC container with decorators, auto-wiring, scopes
// ✅ SIMPLE: Just pass dependencies as parameters
interface Logger {
  info: (msg: string) => void;
}

interface Database {
  query: (sql: string) => Promise<unknown[]>;
}

// Dependencies as parameters
const createUserService = (deps: { logger: Logger; db: Database }) => ({
  async getUser(id: string) {
    deps.logger.info(`Fetching user ${id}`);
    const results = await deps.db.query(`SELECT * FROM users WHERE id = '${id}'`);
    return results[0];
  },
});

// Wire up at app startup
const logger: Logger = { info: console.log };
const db: Database = { query: async () => [] };
const userService = createUserService({ logger, db });

Complexity Indicators

When Code is Too Complex

Questions to Ask

Before adding complexity, ask:

  1. What problem does this solve? - If you can't articulate it, don't add it
  2. Is this solving today's problem or tomorrow's guess? - Solve today's
  3. Can a junior developer understand this? - If not, simplify
  4. Will this be easy to change later? - Simplicity enables change
  5. Am I showing off? - Clever code impresses no one during maintenance

When Complexity IS Justified

/**
 * Sometimes complexity IS necessary - but justify it
 */

// --- SIMPLE CASE: Just need one retry ---
const fetchWithSimpleRetry = async (url: string) => {
  try {
    return await fetch(url);
  } catch {
    return await fetch(url); // One retry, done
  }
};

// --- COMPLEX CASE: Real requirements demand it ---
// Requirements:
// 1. Configurable retry count
// 2. Exponential backoff
// 3. Jitter to prevent thundering herd
// 4. Different strategies for different error types
// 5. Observability (logging, metrics)
// 6. Circuit breaker for cascading failures

interface RetryConfig {
  maxAttempts: number;
  initialDelayMs: number;
  maxDelayMs: number;
  backoffFactor: number;
  jitterFactor: number;
  retryableErrors: string[];
  onRetry?: (attempt: number, error: Error, nextDelayMs: number) => void;
}

interface CircuitBreakerState {
  failures: number;
  lastFailure: number;
  state: "closed" | "open" | "half-open";
}

const createResilientFetcher = (config: RetryConfig) => {
  const circuitBreaker: CircuitBreakerState = {
    failures: 0,
    lastFailure: 0,
    state: "closed",
  };
  
  const calculateDelay = (attempt: number): number => {
    const exponentialDelay = config.initialDelayMs * Math.pow(config.backoffFactor, attempt - 1);
    const cappedDelay = Math.min(exponentialDelay, config.maxDelayMs);
    const jitter = cappedDelay * config.jitterFactor * Math.random();
    return Math.floor(cappedDelay + jitter);
  };
  
  const shouldRetry = (error: Error): boolean => {
    return config.retryableErrors.some(e => error.message.includes(e));
  };
  
  const checkCircuitBreaker = (): boolean => {
    if (circuitBreaker.state === "open") {
      // Check if enough time has passed
      if (Date.now() - circuitBreaker.lastFailure > 30000) {
        circuitBreaker.state = "half-open";
        return true;
      }
      return false;
    }
    return true;
  };
  
  return async (url: string, options?: RequestInit): Promise<Response> => {
    if (!checkCircuitBreaker()) {
      throw new Error("Circuit breaker is open");
    }
    
    let lastError: Error | null = null;
    
    for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
      try {
        const response = await fetch(url, options);
        
        // Reset circuit breaker on success
        circuitBreaker.failures = 0;
        circuitBreaker.state = "closed";
        
        return response;
      } catch (error) {
        lastError = error as Error;
        
        // Update circuit breaker
        circuitBreaker.failures++;
        circuitBreaker.lastFailure = Date.now();
        
        if (circuitBreaker.failures >= 5) {
          circuitBreaker.state = "open";
        }
        
        // Check if we should retry
        if (attempt < config.maxAttempts && shouldRetry(lastError)) {
          const delayMs = calculateDelay(attempt);
          config.onRetry?.(attempt, lastError, delayMs);
          await new Promise(r => setTimeout(r, delayMs));
        }
      }
    }
    
    throw lastError;
  };
};

// This complexity is JUSTIFIED because:
// 1. Each feature was a real requirement
// 2. The system handles millions of requests
// 3. Failure resilience is critical
// 4. The complexity is encapsulated and tested

Use Cases & Problem Solving


Summary

AspectComplex CodeSimple Code
UnderstandingRequires deep studyObvious at a glance
DebuggingHard to traceEasy to follow
ChangingRisky modificationsConfident changes
OnboardingLong ramp-upQuick productivity
TestingMany edge casesStraightforward

Key Takeaway

Simple code is not dumb code. Writing simple code is actually harder than writing complex code. It requires deep understanding to distill a problem to its essence. When in doubt, choose the boring, obvious solution.

On this page