Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 009 / 100

YAGNI and KISS: Design Patterns best practices explained

The two principles that prevent most over-engineering.

Expona Admin8 min read

Introduction

After years of shipping production systems, we've learned that the most resilient code often looks deceptively simple. YAGNI and KISS: Design Patterns best practices explained is about rejecting the urge to architect for scenarios that may never happen. These two principles prevent the slow accumulation of complexity that eventually makes your codebase harder to maintain than it is to rewrite.

In this article, we'll explore why building for today's requirements beats predicting tomorrow's, and why the simplest working design almost always wins.

YAGNI: You Aren't Gonna Need It

YAGNI is a hard-won lesson from teams who've spent weeks building extensibility features that nobody ever used. The principle is straightforward: don't add functionality until you actually need it. Not "might need it someday." Not "would be nice to have if we refactored." Actually need it, in production, today.

In practice, this means resisting the temptation to abstract too early. When you're building an API to export user data, you write code that exports user data. You don't immediately build a generic export engine that handles CSV, JSON, Parquet, and binary formats because "we might want to add more formats later." You export JSON. If a second format becomes a requirement—backed by an actual business need—then you refactor.

Real-world example: The premature abstraction trap

Consider a user authentication service that a mid-sized SaaS team built three years ago. They anticipated supporting OAuth, SAML, OIDC, and custom token schemes. They created a provider abstraction layer with factories, strategy patterns, and middleware chains—all before any customer had asked for OAuth.

The result: 40% of that abstraction layer never got used. When it came time to add a new authentication method two years later, the existing abstraction had accumulated so many assumptions that adding the new provider was still harder than it should have been. The team would have shipped faster and iterated better by handling authentication with simple conditional logic until they had a concrete second requirement.

Compare that to a different team that implemented JWT validation directly in their auth middleware, no abstraction. When they needed to add OAuth, it took a day to add the new path. Their code was easier to read, debug, and test because it wasn't fighting against unnecessary generalization.

Applying YAGNI to design patterns

Design patterns themselves are often misapplied through YAGNI violations. A team doesn't need the Observer pattern until they actually have decoupled components that need to communicate. They don't need a Factory pattern until they're creating objects with complex initialization logic that needs to vary by type. The pattern serves the requirement, not the other way around.

typescript
// YAGNI violation: factory pattern for single use case
interface UserProvider {
  getUser(id: string): Promise;
}

class UserProviderFactory {
  static create(type: 'database' | 'cache'): UserProvider {
    if (type === 'database') return new DatabaseUserProvider();
    if (type === 'cache') return new CacheUserProvider();
    throw new Error('Unknown provider type');
  }
}

// Later: we only ever use 'database'
const provider = UserProviderFactory.create('database');

The factory adds indirection and file size without solving a real problem. A simpler approach: inject the provider directly until you have a genuine need to swap implementations at runtime.

typescript
// YAGNI compliant: direct instantiation
const userProvider = new DatabaseUserProvider();
service.setUserProvider(userProvider);

If testing or runtime configuration later demands swappable providers, you refactor then. You've already proven the interface; extraction is straightforward.

KISS: Keep It Simple, Stupid

KISS states that the simplest working design wins. Not the most elegant. Not the most sophisticated. The one that your team can reason about in six months, that a new hire can understand in an afternoon, that breaks down cleanly when it's time to debug at 2 AM.

Simplicity is not the same as primitive. A simple design can use advanced tools—middleware chains, async patterns, dependency injection. But those tools are chosen because they solve a concrete problem, not because they're clever.

Real-world example: State management gone wrong

We've seen teams rebuild their state management layer twice because they started with a framework designed for Netflix-scale complexity. Reducers, selectors, normalized schemas, time-travel debugging—shipped with zero of those features in use. Six months later, when the codebase has accumulated 40 reducers across 15 files, everyone's frustrated because the abstraction got in the way.

A simpler approach: React hooks and context. No Redux, no MobX, no @ngrx. You have global state? useContext and useState. You need persistence? Store to localStorage in an effect. You need server sync? Use useEffect with proper dependency tracking. This works for 90% of SaaS applications.

typescript
// KISS: simple context-based state
interface AppContextType {
  user: User | null;
  setUser: (user: User | null) => void;
  loading: boolean;
}

const AppContext = createContext(null);

export function AppProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetchCurrentUser().then(setUser).finally(() => setLoading(false));
  }, []);

  return (
    
      {children}
    
  );
}

This is readable, testable, and sufficient. If you later need time-travel debugging, middleware effects, or normalized caching, you migrate to Redux. But you do so because you've hit a concrete limitation, not because you predicted it five months ago.

Make It Easy to Change Rather Than Predicting the Future

The strongest teams don't try to predict the future. They build systems that are easy to change. This is a subtle shift in mindset: instead of "how can I architect this to handle any scenario," ask "how can I write this so that when requirements change, I can pivot without rewriting?"

Easy-to-change code typically has these properties:

  • Clear responsibility boundaries (single responsibility principle)
  • Minimal coupling between modules
  • Well-named functions and types (no cryptic abbreviations)
  • Tests that document intended behavior
  • Straightforward control flow (fewer nested conditionals)

Example: Building for changeability in a payment pipeline

You're adding a payment processor integration. Two options:

Option A: Predict you'll add five payment processors. Build an abstraction layer with a provider interface, factory, and strategy loader. Create a plugin system. Spend three days on infrastructure. Your initial Stripe integration ends up at 500 lines of abstraction code and 50 lines of actual Stripe logic.

Option B: Build Stripe integration simply. Create a module that talks to Stripe's API. Keep that code in a single file. Stripe SDK calls, environment variables, response parsing—all clear and collocated. Spend a few hours. 150 lines of code, all of it useful.

Six months later: customer asks for PayPal support. In Option A, you plug in a PayPal provider following the established pattern. In Option B, you duplicate the Stripe module, replace SDK calls with PayPal calls, and adapt response parsing. Yes, there's duplication—but the integration took four hours instead of fighting the abstraction.

After PayPal, you've now seen two payment processors. Extract common patterns into a helper module. Now your third processor (Square, Adyen, whatever) is truly easy to add—because you've learned what's actually common. You avoided premature extraction.

Complexity Is a Cost You Pay Forever

Every line of code is a liability. Every abstraction layer is a cognitive load. Every architectural decision is a constraint on future changes. Most teams underestimate this cost because it's distributed across months: a little slower to onboard new engineers, a little harder to debug, a little longer to add features.

Sum that across 18 months and you've lost six months of velocity.

Complex systems are also harder to test, monitor, and operate. A payment processing function with five layers of abstraction is harder to trace through when something goes wrong at 3 AM. A state management system with normalized caches, selectors, and middleware chains has more surfaces where bugs hide.

The compound cost of complexity

Consider a mid-level codebase with moderate over-engineering across six domains (auth, payments, notifications, data export, user profiles, reporting). Each domain has one or two unnecessary abstraction layers. Not egregious—just "future-proofing."

That costs:

  • Onboarding: New engineers take an extra week to understand the indirection
  • Feature velocity: Each change requires mental navigation through abstraction layers
  • Bug investigation: Five layers deep means five places a bug could hide
  • Testing: More mocks, more setup, longer test suites
  • Refactoring: Changing core logic means updating multiple abstraction layers

Across a year, a small team of five engineers might lose 10–15% of their capacity to fighting unnecessary complexity. For a funded company, that's real money.

The simplest systems compound in the opposite direction. A clear, straightforward payment processor integration is faster to build, faster to debug, faster to extend. Every engineer on the team can reason about it in minutes. Tests run faster because there's less to mock. When requirements shift, there's less to refactor.

Common Mistakes

Confusing YAGNI with "never plan ahead"

YAGNI doesn't mean you should be surprised by change. It means you shouldn't build machinery to handle change you don't understand yet. Understanding your domain and anticipating broad categories of change (e.g., "we'll probably support multiple payment processors") is fine. Building the provider abstraction today is not.

Mistaking simple for naive

Simple code can use sophisticated patterns—async/await, middleware, dependency injection, event streams. The point is that those tools are serving a real need, not present "just in case." A simple Node.js service can use Redis effectively. It's the unmotivated abstraction layers that kill simplicity, not the tools themselves.

Refactoring too late

The opposite extreme: staying with inadequate solutions too long. If your simple context-based state management has become genuinely hard to reason about because you're managing async operations across dozens of components, it's time to migrate to Redux or Zustand. KISS means extracting complexity when it's real, not ignoring it when it's acute.

Treating YAGNI and KISS as constraints on growing systems

These principles guide decisions when you're building. They're not reasons to avoid refactoring a healthy codebase. Once a system is live and you've learned what actually matters, thoughtful refactoring to introduce strategic abstractions is good engineering. YAGNI says "don't build it before you know you need it." It doesn't say "never build abstractions."

Key Takeaways

  • Build for today's requirements, not tomorrow's hypotheticals. YAGNI prevents the slow accumulation of unused abstraction layers.
  • The simplest working design wins. Sophisticated patterns should serve concrete problems, not aesthetics.
  • Prioritize changeability over predictability. Clear, well-separated code is easier to refactor than complex systems built to handle undefined futures.
  • Complexity compounds as a cost. Small over-engineering choices accumulate into lost productivity across months.
  • Extract abstractions after you've seen the pattern twice in production. Your first implementation teaches you what's actually common.
  • Simple doesn't mean primitive. Use the right tools (async patterns, middleware, dependency injection) when they solve a real problem.
  • Refactor when the codebase genuinely demands it, not before. Live systems have the information your planning meeting didn't.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles