Home / Notebooks / Good Software Essentials
Good Software Essentials
intermediate

Software Design Essentials

Core concepts and building blocks of good software design

April 26, 2026
Updated regularly

Software Design Essentials

Core concepts every developer should know to design software that is maintainable, scalable, and easy to understand.

What is Software Design?

Software design is the process of defining structure, components, interfaces, and behavior before writing code:

  • Blueprint: Guides implementation decisions
  • Communication: Shared language between developers
  • Quality gate: Catch problems before they become code
  • Longevity: Well-designed systems are easier to extend and maintain
  • > "Good design is not about making grand plans, but about making things easy to change." - Martin Fowler

    Separation of Concerns (SoC)

    Each part of the system should address one distinct concern:

    // ❌ BAD: Mixed concerns in one place
    function renderUserProfile(userId) {
      const user = db.query(`SELECT * FROM users WHERE id = ${userId}`);
      const html = `<div class="profile">
        <h1>${user.name}</h1>
        <p>${user.email}</p>
      </div>`;
      document.getElementById('app').innerHTML = html;
    }
    
    // ✅ GOOD: Concerns separated
    // Data layer
    function fetchUser(userId) {
      return db.query('SELECT * FROM users WHERE id = ?', [userId]);
    }
    
    // Presentation layer
    function UserProfile({ user }) {
      return (
        <div className="profile">
          <h1>{user.name}</h1>
          <p>{user.email}</p>
        </div>
      );
    }
    
    // Orchestration layer
    async function renderUserProfile(userId) {
      const user = await fetchUser(userId);
      render(<UserProfile user={user} />, document.getElementById('app'));
    }
    

    Abstraction

    Hide complexity behind simple, well-defined interfaces:

    // ❌ BAD: Caller must know implementation details
    const response = await fetch('https://api.example.com/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
      body: JSON.stringify({ name, email }),
    });
    const data = await response.json();
    if (!response.ok) throw new Error(data.message);
    
    // ✅ GOOD: Abstracted behind a clean interface
    const user = await userApi.create({ name, email });
    

    Levels of Abstraction

    Stay consistent within each level — don't mix high and low level:

    // ❌ BAD: Mixed abstraction levels
    function processOrder(order) {
      validateOrder(order);                        // high level
      const tax = order.total * 0.08;             // low level
      applyDiscount(order);                        // high level
      order.grandTotal = order.total + tax;       // low level
      sendConfirmation(order);                     // high level
    }
    
    // ✅ GOOD: Consistent abstraction level
    function processOrder(order) {
      validateOrder(order);
      calculateOrderTotals(order);
      applyDiscount(order);
      sendConfirmation(order);
    }
    

    Encapsulation

    Keep data and behavior together, hide internal details:

    // ❌ BAD: Data exposed, logic scattered
    const account = { balance: 1000, owner: 'Alice' };
    
    function deposit(account, amount) {
      account.balance += amount; // anyone can mutate directly too
    }
    
    function withdraw(account, amount) {
      if (amount > account.balance) throw new Error('Insufficient funds');
      account.balance -= amount;
    }
    
    // ✅ GOOD: Encapsulated in a class
    class BankAccount {
      #balance;
      #owner;
    
      constructor(owner, initialBalance = 0) {
        this.#owner = owner;
        this.#balance = initialBalance;
      }
    
      deposit(amount) {
        if (amount <= 0) throw new Error('Deposit must be positive');
        this.#balance += amount;
      }
    
      withdraw(amount) {
        if (amount > this.#balance) throw new Error('Insufficient funds');
        this.#balance -= amount;
      }
    
      get balance() {
        return this.#balance;
      }
    }
    

    Coupling & Cohesion

    The two most important metrics for evaluating design:

    Coupling — how much modules depend on each other

    Aim for low coupling:

    // ❌ HIGH COUPLING: OrderService reaches into UserService internals
    class OrderService {
      placeOrder(order) {
        const user = UserService.userRepository.db.find(order.userId); // knows too much
        if (!user.subscription.isPremium) throw new Error('Premium only');
      }
    }
    
    // ✅ LOW COUPLING: Through a defined interface
    class OrderService {
      constructor(private userService: UserService) {}
    
      placeOrder(order) {
        if (!this.userService.isPremiumUser(order.userId)) {
          throw new Error('Premium only');
        }
      }
    }
    

    Aim for high cohesion:

    // ❌ LOW COHESION: Unrelated responsibilities grouped together
    class Utils {
      formatDate(date) {}
      sendEmail(to, subject, body) {}
      calculateTax(amount) {}
      resizeImage(image, width, height) {}
      generatePDF(data) {}
    }
    
    // ✅ HIGH COHESION: Related responsibilities together
    class DateFormatter { formatDate(date) {} }
    class EmailService { sendEmail(to, subject, body) {} }
    class TaxCalculator { calculateTax(amount, rate) {} }
    

    Modularity

    Break systems into independent, interchangeable modules:

    // ✅ GOOD: Clear module boundaries
    src/
    ├── auth/
    │   ├── authService.ts       # Authentication logic
    │   ├── authMiddleware.ts    # Request authentication
    │   └── authRoutes.ts       # Auth endpoints
    ├── users/
    │   ├── userService.ts      # User business logic
    │   ├── userRepository.ts   # Data access
    │   └── userRoutes.ts       # User endpoints
    ├── orders/
    │   ├── orderService.ts
    │   ├── orderRepository.ts
    │   └── orderRoutes.ts
    └── shared/
        ├── errors.ts           # Shared error types
        └── validators.ts       # Shared validation
    

    Each module:

  • Has a clear purpose
  • Exposes a minimal public API
  • Can be tested in isolation
  • Can be replaced without affecting others
  • Composition over Inheritance

    Prefer assembling behavior from small pieces over deep class hierarchies:

    // ❌ BAD: Deep inheritance hierarchy
    class Animal {}
    class Pet extends Animal {}
    class Dog extends Pet {}
    class GuideDog extends Dog {}
    class PoliceDog extends Dog {}
    // GuideDog and PoliceDog can't share traits without re-inheritance
    
    // ✅ GOOD: Compose behavior
    const canBark = (state) => ({
      bark: () => console.log(`${state.name} says: Woof!`),
    });
    
    const canGuide = (state) => ({
      guide: () => console.log(`${state.name} is guiding you`),
    });
    
    const canPatrol = (state) => ({
      patrol: () => console.log(`${state.name} is on patrol`),
    });
    
    function createGuideDog(name) {
      const state = { name };
      return { ...canBark(state), ...canGuide(state) };
    }
    
    function createPoliceDog(name) {
      const state = { name };
      return { ...canBark(state), ...canPatrol(state) };
    }
    

    Designing for Change

    Design systems so that change is local — modifying one feature shouldn't ripple across the codebase:

    // ❌ BAD: Adding a new payment method requires touching many files
    function checkout(cart, paymentType) {
      if (paymentType === 'card') { /* ... */ }
      else if (paymentType === 'paypal') { /* ... */ }
      else if (paymentType === 'crypto') { /* ... */ } // added everywhere
    }
    
    // ✅ GOOD: New payment method = new class, nothing else changes
    interface PaymentMethod {
      charge(amount: number): Promise<Receipt>;
    }
    
    class CardPayment implements PaymentMethod { /* ... */ }
    class PayPalPayment implements PaymentMethod { /* ... */ }
    class CryptoPayment implements PaymentMethod { /* ... */ } // just add here
    
    class CheckoutService {
      constructor(private payment: PaymentMethod) {}
      async checkout(cart) {
        return this.payment.charge(cart.total);
      }
    }
    

    Common Design Mistakes

    MistakeProblemFix
    God objectOne class knows everythingSplit into focused classes
    Shotgun surgeryOne change touches many filesCentralize related logic
    Feature envyClass uses another class's data more than its ownMove behavior to where the data is
    Primitive obsessionUsing primitives instead of domain objectsIntroduce value objects
    Anemic domain modelData classes with no behaviorAdd behavior where data lives

    Tips

  • Name things honestly — a class called UserManager that sends emails has a naming problem
  • Sketch before you code — even a rough diagram reveals design issues early
  • Follow the data — find where your data lives, and put related behavior next to it
  • Design in layers — UI, business logic, data access should be distinct
  • Question every dependency — each one is coupling you're accepting
  • Small interfaces — prefer many small interfaces over one large one
  • Resources

  • A Philosophy of Software Design — John Ousterhout
  • Designing Data-Intensive Applications — Martin Kleppmann
  • Clean Architecture — Robert C. Martin
  • Refactoring Guru — Design Patterns
  • Topics

    Software DesignArchitectureBest PracticesSoftware Engineering

    Found This Helpful?

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