YAGNI Principle: You Aren't Gonna Need It
Every experienced developer has done it. You're building a simple user registration system, and suddenly you're designing an abstract factory pattern to support authentication providers you might...
Key Insights
- YAGNI isn’t about writing bad code—it’s about writing the right code at the right time, avoiding speculative features that may never be needed
- Premature features carry hidden costs: increased complexity, maintenance burden, and cognitive overhead that slows down actual development
- The principle works hand-in-hand with good design; use abstractions when they solve today’s problems, not tomorrow’s hypotheticals
The Temptation to Over-Engineer
Every experienced developer has done it. You’re building a simple user registration system, and suddenly you’re designing an abstract factory pattern to support authentication providers you might need “someday.” You add plugin hooks for features nobody has requested. You build a configuration system flexible enough to handle scenarios that exist only in your imagination.
YAGNI—“You Aren’t Gonna Need It”—emerged from Extreme Programming (XP) in the late 1990s as a direct response to this tendency. Ron Jeffries, one of XP’s founders, put it bluntly: implement things when you actually need them, never when you just foresee that you might need them.
The principle sounds obvious, yet developers routinely ignore it. Why? Because we’re trained to anticipate problems. We’ve been burned by rigid systems that couldn’t adapt. We want to demonstrate architectural sophistication. And frankly, building elaborate abstractions is more intellectually satisfying than solving mundane business problems.
But that satisfaction comes at a cost.
The Real Cost of Premature Features
Unused code isn’t free. Every line you write must be:
- Read and understood by every developer who touches that file
- Maintained when dependencies update or APIs change
- Tested (or worse, left untested and rotting)
- Compiled and deployed with every release
Studies consistently show that 60-80% of software features are rarely or never used. When you build speculatively, you’re likely contributing to that waste.
Consider the maintenance burden. That clever plugin system you built for hypothetical extensibility? It now constrains how you can refactor the core logic. The abstract factory pattern you added “for flexibility”? It’s three extra files that every new team member must understand before making simple changes.
There’s also opportunity cost. Time spent building unused features is time not spent on features users actually need. In a 2019 analysis of enterprise projects, teams that practiced strict YAGNI delivered 30% more user-facing functionality in the same timeframe.
YAGNI in Practice: Before and After
Let’s examine a real example. Here’s a user service that’s been over-engineered for hypothetical requirements:
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional, List
# Abstract factory for "future" user storage backends
class UserRepositoryFactory(ABC):
@abstractmethod
def create_repository(self, config: Dict[str, Any]) -> 'UserRepository':
pass
class PostgresRepositoryFactory(UserRepositoryFactory):
def create_repository(self, config: Dict[str, Any]) -> 'UserRepository':
return PostgresUserRepository(config)
# Base repository with plugin hooks we "might need"
class UserRepository(ABC):
def __init__(self):
self._pre_save_hooks: List[callable] = []
self._post_save_hooks: List[callable] = []
def register_pre_save_hook(self, hook: callable):
self._pre_save_hooks.append(hook)
@abstractmethod
def save(self, user: 'User') -> None:
pass
@abstractmethod
def find_by_id(self, user_id: str) -> Optional['User']:
pass
@abstractmethod
def find_by_email(self, email: str) -> Optional['User']:
pass
# Concrete implementation
class PostgresUserRepository(UserRepository):
def __init__(self, config: Dict[str, Any]):
super().__init__()
self._connection_string = config.get('connection_string')
self._pool_size = config.get('pool_size', 10)
self._retry_strategy = config.get('retry_strategy', 'exponential')
def save(self, user: 'User') -> None:
for hook in self._pre_save_hooks:
hook(user)
# Actual save logic
for hook in self._post_save_hooks:
hook(user)
def find_by_id(self, user_id: str) -> Optional['User']:
pass # Implementation
def find_by_email(self, email: str) -> Optional['User']:
pass # Implementation
# Service layer with "extensible" validation
class UserService:
def __init__(self, repository_factory: UserRepositoryFactory, config: Dict[str, Any]):
self._repository = repository_factory.create_repository(config)
self._validators: List['UserValidator'] = []
def register_validator(self, validator: 'UserValidator'):
self._validators.append(validator)
def create_user(self, email: str, name: str) -> 'User':
user = User(email=email, name=name)
for validator in self._validators:
validator.validate(user)
self._repository.save(user)
return user
This code has abstract factories, plugin hooks, configurable retry strategies, and extensible validation—none of which are currently used. The actual requirement? Save and retrieve users from Postgres.
Here’s the YAGNI version:
from dataclasses import dataclass
from typing import Optional
import psycopg2
@dataclass
class User:
id: Optional[str]
email: str
name: str
class UserRepository:
def __init__(self, connection_string: str):
self._connection_string = connection_string
def save(self, user: User) -> User:
with psycopg2.connect(self._connection_string) as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO users (email, name) VALUES (%s, %s) RETURNING id",
(user.email, user.name)
)
user.id = cur.fetchone()[0]
conn.commit()
return user
def find_by_email(self, email: str) -> Optional[User]:
with psycopg2.connect(self._connection_string) as conn:
with conn.cursor() as cur:
cur.execute("SELECT id, email, name FROM users WHERE email = %s", (email,))
row = cur.fetchone()
return User(id=row[0], email=row[1], name=row[2]) if row else None
class UserService:
def __init__(self, repository: UserRepository):
self._repository = repository
def create_user(self, email: str, name: str) -> User:
if self._repository.find_by_email(email):
raise ValueError("Email already exists")
return self._repository.save(User(id=None, email=email, name=name))
Same functionality. A third of the code. Dramatically easier to understand, test, and modify.
YAGNI vs. Good Architecture: Finding the Balance
YAGNI doesn’t mean abandoning design principles. It means applying them to solve actual problems, not imaginary ones.
Here’s the distinction. This is appropriate abstraction—using dependency injection for testability:
from abc import ABC, abstractmethod
class UserRepository(ABC):
@abstractmethod
def save(self, user: User) -> User:
pass
class PostgresUserRepository(UserRepository):
def save(self, user: User) -> User:
# Real database logic
pass
class UserService:
def __init__(self, repository: UserRepository):
self._repository = repository
The interface exists because we need it today—for testing. We can inject a mock repository in tests without touching a database.
This is unnecessary abstraction:
class UserRepositoryFactory(ABC):
@abstractmethod
def create(self, config: Dict) -> UserRepository:
pass
class UserRepositoryFactoryProvider:
def get_factory(self, database_type: str) -> UserRepositoryFactory:
# Supporting MySQL, MongoDB, Cassandra "just in case"
pass
Nobody asked for multiple database support. If they do, you can add it then.
YAGNI is also not an excuse to skip:
- Security measures: Authentication, authorization, and input validation are current requirements
- Testing: Tests solve today’s problem of ensuring correctness
- Error handling: Users will encounter errors today, not hypothetically
- Logging: You need observability from day one
When to Ignore YAGNI
YAGNI has legitimate exceptions. Ignore it when:
Regulatory requirements are documented. If compliance mandates audit logging, build it now—even if the feature it audits doesn’t exist yet.
API contracts exist with external consumers. Once you publish an API, changing it has real costs. Think carefully about versioning and extensibility before release.
Scaling requirements are known and imminent. If you’re launching to 100,000 users next month, don’t build a system that only handles 100.
The cost of retrofitting is genuinely prohibitive. Some architectural decisions—database choice, fundamental data models—are expensive to change. These deserve more upfront thought.
The key word is “known.” Not suspected, not feared—known. Document the requirement that justifies the complexity.
Practical Guidelines for Teams
Adopt these practices to keep YAGNI alive in your team:
The Three Strikes Rule. Don’t abstract until you’ve written similar code three times. The first time, just write it. The second time, note the duplication. The third time, you have enough examples to design a good abstraction.
Time-box spikes. When someone argues for a complex solution, time-box an investigation. “Let’s spend two hours exploring whether we actually need this.” Often, the simpler solution proves sufficient.
Document deferred decisions. Create a “future considerations” document. When someone suggests a feature you’re not building, add it there. This acknowledges the idea without committing code.
Ask these code review questions:
- What current requirement does this code satisfy?
- If we removed this abstraction, what would break today?
- Can we ship without this and add it when needed?
Measure feature usage. Track which features users actually use. Data kills speculation faster than any principle.
Embrace Iterative Development
YAGNI isn’t about building fragile systems or ignoring the future. It’s about acknowledging a fundamental truth: we’re bad at predicting the future.
Requirements change. Business pivots. That plugin system you built for extensibility? The company got acquired before anyone used it. The multi-tenant architecture you designed? The product stayed single-tenant forever.
Build what you need now. Build it well. When new requirements emerge—real requirements, from real users—refactor. Modern development practices make refactoring safe: version control, automated testing, continuous integration.
The code you don’t write has no bugs, needs no maintenance, and confuses no one. That’s not laziness. That’s engineering discipline.
Ship the simple version. Learn from real usage. Iterate. You aren’t gonna need it—until you do. And when you do, you’ll know exactly what to build.