Design Principles

Author: Htunn Thu Thu

Date: January 4, 2026

Tags: #SoftwareDesign #TypeScript #DRY #KISS #YAGNI #BestPractices

Introduction

Beyond SOLID, there are fundamental design principles that guide everyday coding decisions. When I started building my IoT monitoring platform, I overengineered everything—abstract factories for simple objects, inheritance hierarchies five levels deep, and "flexible" architectures for features I never built.

These principles—DRY, KISS, YAGNI, Separation of Concerns, and Composition over Inheritance—taught me to build software that's simple, maintainable, and just complex enough to solve real problems.


DRY: Don't Repeat Yourself

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

The Problem I Had

In my Agentic LLM Search project, I had error handling code duplicated everywhere:

// ❌ BAD: Repeated error handling logic

async function searchWeb(query: string): Promise<SearchResult[]> {
  try {
    const response = await fetch(`https://api.duckduckgo.com/?q=${query}`);
    if (!response.ok) {
      console.error(`Search failed: ${response.status} ${response.statusText}`);
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    const data = await response.json();
    return data.results;
  } catch (error) {
    console.error('Error in searchWeb:', error);
    if (error instanceof TypeError) {
      throw new Error('Network error - check your connection');
    }
    throw error;
  }
}

async function queryLLM(prompt: string): Promise<string> {
  try {
    const response = await fetch('http://localhost:8000/generate', {
      method: 'POST',
      body: JSON.stringify({ prompt })
    });
    if (!response.ok) {
      console.error(`LLM failed: ${response.status} ${response.statusText}`);
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    const data = await response.json();
    return data.text;
  } catch (error) {
    console.error('Error in queryLLM:', error);
    if (error instanceof TypeError) {
      throw new Error('Network error - check your connection');
    }
    throw error;
  }
}

// Same pattern repeated in 15+ functions!

My Solution: Extract Common Logic

I created reusable utilities:

When DRY Goes Wrong

I learned the hard way that "similar looking" code isn't always duplication:

My DRY Rule

Extract when:

  • Logic is identical and will change together

  • Changes to one copy should affect all copies

  • The duplication is accidental, not intentional

Keep separate when:

  • Similar code serves different business purposes

  • Changes to one should NOT affect the other

  • Different stakeholders own different pieces


KISS: Keep It Simple, Stupid

Simplicity should be a key goal, and unnecessary complexity avoided.

The Problem I Had

In my MCP server, I tried to make port scanning "flexible" for future needs:

My Solution: Start Simple

I rewrote it with just what I actually needed:

When Complexity is Justified

Later, I did need to add timeout configuration and parallel scanning. I added complexity only when the requirement was real:

My KISS Rule

  1. Start with the simplest solution that works

  2. Add complexity only when you have a real requirement

  3. Refactor when simple becomes inadequate

  4. Delete code if a simpler approach emerges


YAGNI: You Aren't Gonna Need It

Don't implement something until it is necessary.

The Problem I Had

Building my IoT platform, I anticipated "future requirements":

Cost of this over-engineering:

  • 2 weeks development time wasted

  • Increased codebase complexity

  • More bugs in code nobody used

  • Harder to find the methods I actually needed

My Solution: Build What's Needed

I refactored to only what I actually use:

When to Plan Ahead

YAGNI doesn't mean zero planning. I do consider:

My YAGNI Rule

Build it when:

  • You have a concrete requirement RIGHT NOW

  • Stakeholders are actively asking for it

  • You're currently blocked without it

Don't build it when:

  • "We might need this someday"

  • "It would be cool to have"

  • "What if the requirements change to..."


Separation of Concerns

Different concerns should be separated into different modules.

The Problem I Had

My early MCP server mixed everything together:

My Solution: Separate Layers

I separated concerns into distinct layers:

Benefits I Gained

  • Testability: Each layer can be tested independently

  • Reusability: Logger and AuditLog used across my entire project

  • Maintainability: Database changes don't affect HTTP handling

  • Clarity: Each file has a clear, single purpose


Composition Over Inheritance

Favor object composition over class inheritance.

The Problem I Had

I tried using inheritance for my sensor types:

My Solution: Use Composition

I broke capabilities into composable pieces:

Benefits I Gained

  • Flexibility: Mix and match capabilities without inheritance constraints

  • Testability: Mock individual capabilities easily

  • Reusability: Capabilities work with any sensor type

  • Clarity: Each capability is self-contained


Applying Principles Together

Here's how I apply all these principles in a real service from my IoT platform:


Conclusion

These principles guide my daily development:

  • DRY: Extract duplication, but respect different purposes

  • KISS: Start simple, add complexity only when justified

  • YAGNI: Build what you need now, not what you might need

  • Separation of Concerns: Organize code by responsibility

  • Composition over Inheritance: Favor flexibility over rigid hierarchies

The key is balance—apply principles pragmatically, not dogmatically. Simple code that solves real problems beats "perfect" code that over-engineers imaginary ones.

What's Next?

References


Previous: ← SOLID Principles | Next: Creational Patterns →

Last updated