YAGNI Principle
Don't implement something until it is actually needed — avoid speculative development.
YAGNI Principle: A Complete Guide with Examples in 11 Programming Languages
YAGNI — You Aren't Gonna Need It — is a software development principle from Extreme Programming (XP) that states: do not implement something until it is actually needed. The phrase was coined by Ron Jeffries, one of the founders of XP, as a direct rebuttal to the common developer impulse to build features, abstractions, or infrastructure "just in case" they turn out to be useful later.
The principle rests on a simple and often underestimated observation: the cost of building something you don't need is never zero. The unused code must be written, reviewed, tested, debugged, documented, and maintained — often indefinitely. And when the actual requirement finally arrives, it frequently differs from what was imagined, making the pre-built code wrong, misaligned, or actively in the way of implementing what was genuinely needed.
In this comprehensive guide, we'll explore the YAGNI principle with real-world examples in Python, TypeScript, Java, JavaScript, C#, PHP, Go, Rust, Dart, Swift, and Kotlin — complete with language-specific best practices, common pitfalls, and the discipline that produces focused, maintainable systems.
Table of Contents
- What is the YAGNI Principle?
- Why Follow YAGNI?
- YAGNI Comparison with Related Principles
- YAGNI in Depth
- Concept Diagram
- Beginner-Friendly Example
- Production-Ready Example
- Real-World Use Cases
- Language-Specific Mistakes and Anti-Patterns
- Frequently Asked Questions
- Key Takeaways
What is the YAGNI Principle?
YAGNI is the discipline of building what is needed today, based on requirements that actually exist, rather than building for a future that may never arrive in the form imagined. It is a refusal to let speculation drive implementation.
The principle sounds deceptively obvious. In practice, it runs against a deep developer instinct to be thorough, forward-thinking, and prepared. When writing a user authentication system, it feels responsible to add support for OAuth providers that aren't needed yet, a plugin hook for custom authentication strategies that no one has asked for, and a role hierarchy that supports future permission levels. Each addition feels like a gift to the future. In practice, each is a cost imposed on the present — and frequently a constraint imposed on the future, because the pre-built structure must be worked around when the actual requirement arrives.
YAGNI violations arise from several distinct motivations:
Speculative generality: Building abstract, configurable, or extensible systems for requirements that have been imagined rather than requested. A payment processor that handles twelve currencies when the product only launches in one. An API that accepts a version parameter when there is one version and no roadmap for a second.
Gold plating: Adding features, polish, or capability beyond what was requested, because the developer believes the additions would be valuable. The feature may be genuinely good — but it was not asked for, it was not prioritized, and its value was not validated.
Infrastructure speculation: Building message queues, caching layers, distributed systems, or microservice architectures before the operational scale that justifies them has been reached or demonstrated. Each infrastructure component has a real cost: operational complexity, monitoring overhead, deployment complexity, and on-call burden.
Defensive parameterization: Adding function parameters, configuration options, or flags to make future changes easier, when there is no current reason to believe those changes will be needed. Every unused parameter is a question mark in the function signature that future readers must answer before they can safely call the function.
Premature abstraction: Extracting a base class, interface, or shared module for a pattern that appears in one place, on the theory that it will appear in more places later. The abstraction is not earned by two actual cases; it is imposed on one actual case in anticipation of a second that has not arrived.
Why Follow YAGNI?
The costs of building what you don't need are concrete, compounding, and frequently underestimated:
-
Implementation cost: Writing speculative code takes real time that could be spent on actual requirements. Every hour spent building an unused feature is an hour not spent on a needed one.
-
Testing cost: Unused code still needs tests. Tests for speculative functionality must be written, maintained, and kept passing as the codebase evolves — for functionality that may never be invoked in production.
-
Maintenance cost: Every line of code is a permanent liability until it is deleted. Speculative code must be maintained indefinitely — updated when dependencies change, fixed when bugs are found, migrated when the platform changes. If the feature is never needed, this maintenance cost is pure waste.
-
Complexity cost: Speculative code makes the codebase harder to understand. A developer reading a class with ten methods must understand all ten to form a reliable mental model of the class — even if three of those methods are unused speculative additions. Unused abstractions, parameters, and extension points add cognitive noise that slows every future developer who works with that code.
-
Opportunity cost: Every speculative feature that was built could have been replaced by an actual feature that was needed. The true cost of YAGNI violations is not just the wasted effort — it is the delayed or missed delivery of genuine value.
-
Misalignment risk: When the anticipated requirement finally arrives, it rarely arrives in exactly the form imagined. The pre-built code handles the imagined version; the actual requirement needs something different. The developer must now work around a structure that was built to solve a different problem — or tear it out and start fresh, having paid the full implementation cost twice.
-
Decision-making cost: Speculative code makes design decisions prematurely, when information is least complete. Deferring a decision until it is needed means making it with full knowledge of the actual requirement — producing a better design with less effort.
YAGNI Comparison with Related Principles
| Principle | Core Statement | Primary Target | Guiding Question |
|---|---|---|---|
| YAGNI | Don't build it until it's needed | Unneeded features, abstractions, infrastructure | Has this actually been asked for? |
| KISS | Keep solutions as simple as possible | All forms of unnecessary complexity | Is this the simplest solution that works? |
| DRY | Every piece of knowledge has one representation | Duplication of knowledge | Is this defined in more than one place? |
| OAOO | Each behavior implemented exactly once | Duplicated behavioral implementations | Is this behavior implemented more than once? |
| SSOT | Every fact stored in exactly one place | Data and state redundancy | Does this data live in more than one place? |
| Lean / JIT | Produce only what is needed, when it is needed | Waste in production processes | Does this step add value now? |
YAGNI vs. KISS: YAGNI and KISS are complementary but distinct. YAGNI is specifically about features and functionality that have not been requested — it is the principle that tells you not to build something. KISS is about the complexity of what you do build — it is the principle that tells you how to build it. You can satisfy YAGNI (only building what was asked) while violating KISS (implementing the requested feature in an unnecessarily complex way). You can also satisfy KISS (implementing simply) while violating YAGNI (building a feature that was never requested but is simple in its implementation).
YAGNI vs. DRY: DRY is about eliminating the duplication of existing knowledge. YAGNI is about not adding new knowledge prematurely. They rarely conflict directly, though following DRY too aggressively can lead to YAGNI violations: abstracting a shared module for a pattern that appears once (on the speculation it will appear again) is both a DRY motivation and a YAGNI violation.
YAGNI vs. Open/Closed Principle: The Open/Closed Principle (OCP) says classes should be open for extension but closed for modification — which seems to argue for pre-building extension points. YAGNI says: build extension points when you have something to extend. The resolution: OCP describes how to structure code when extension is a genuine, current requirement. YAGNI says don't add OCP-style extension machinery until you actually need to extend something.
YAGNI and technical debt: YAGNI is sometimes misread as permission to write quick-and-dirty code. That reading is wrong. YAGNI says don't build features that aren't needed — it says nothing about the quality of the features that are built. Building something well (with appropriate tests, clear structure, and good naming) is not a YAGNI violation even if it takes more time than a rough implementation. Building something extra is.
YAGNI in Depth
The Two Costs YAGNI Prevents
Ron Jeffries articulated two specific costs that YAGNI violations impose:
Cost of building: The immediate cost — time, effort, and complexity — of implementing something that is not needed. This cost is paid now, in the current sprint, while other needed things are not being built.
Cost of carrying: The ongoing cost — maintenance, testing, cognitive overhead — of keeping speculative code alive in the codebase. This cost accumulates indefinitely until the code is either used or deleted.
YAGNI says both costs are wastes. Every dollar and hour spent on speculative work is a dollar and hour that could have been spent on validated work.
When YAGNI Does NOT Apply
YAGNI is frequently misunderstood as a reason to skip foundational design work. It is not. There are specific situations where building ahead of current need is correct:
Core architectural decisions that are expensive to reverse: Choosing a relational vs. document database, a monolith vs. microservice architecture, or a synchronous vs. event-driven communication style. These decisions are expensive to reverse, so they justify more upfront thought — but thought, not implementation of speculative features.
Known, committed future requirements: If a future requirement is scheduled, resourced, and committed — not merely imagined — it may be appropriate to design the current system with that requirement in mind. The key word is committed: a backlog item with a story point estimate and a sprint assignment, not a comment in a meeting that "we might want to support X someday."
Non-functional requirements that govern architecture: Security, compliance, accessibility, and performance requirements that apply to the entire system should inform the architecture from the start. A system that must be GDPR-compliant cannot defer that requirement until it becomes urgent.
Refactoring that enables current work: Extracting a shared function because two current implementations duplicate the same logic is DRY, not YAGNI. The abstraction is earned by two current cases, not anticipated for a future one.
The YAGNI Decision Framework
Before building anything that was not explicitly requested, ask:
- Is this in the requirements? If no current user story, ticket, or specification calls for this, stop.
- Is it committed for the near future? If it is scheduled and resourced, the design may reasonably accommodate it. If it is speculative, stop.
- What is the cost of adding it later? If the cost of adding it when it is needed is low, definitely don't build it now. If the cost of retrofitting it is genuinely high, weigh that carefully — but be honest about whether the feature will actually be needed.
- What is the cost of building it now? Every hour is an opportunity cost. What validated requirement is being deferred?
Concept Diagram
Beginner-Friendly Example
The clearest YAGNI illustration: a notification sender that currently only needs to send email. The YAGNI violation pre-builds support for SMS, push notifications, Slack, and webhooks — none of which have been requested. The YAGNI solution sends email correctly and stops there. When SMS is genuinely needed, it takes minutes to add.
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import smtplib
from email.message import EmailMessage
# ════════════════════════════════════════════════════════════════
# YAGNI VIOLATION — Pre-built for 5 channels, only 1 is needed
# ════════════════════════════════════════════════════════════════
from abc import ABC, abstractmethod
from enum import Enum
class NotificationChannel(Enum):
EMAIL = "email"
SMS = "sms" # Not requested
PUSH = "push" # Not requested
SLACK = "slack" # Not requested
WEBHOOK = "webhook" # Not requested
class NotificationSender(ABC):
@abstractmethod
def send(self, recipient: str, subject: str, body: str) -> bool: ...
class EmailSender(NotificationSender):
def __init__(self, smtp_host: str, smtp_port: int, username: str, password: str) -> None:
self._host, self._port = smtp_host, smtp_port
self._username, self._password = username, password
def send(self, recipient: str, subject: str, body: str) -> bool:
# ... real email logic
print(f"[EMAIL] → {recipient}: {subject}")
return True
class SmsSender(NotificationSender):
"""Not requested. Built speculatively. Must be maintained."""
def __init__(self, api_key: str, from_number: str) -> None:
self._api_key = api_key # Which SMS provider? Unknown — not requested yet
self._from = from_number
def send(self, recipient: str, subject: str, body: str) -> bool:
# Placeholder — real provider not decided because this was never requested
print(f"[SMS] → {recipient}: {body[:160]}")
return True
class PushSender(NotificationSender):
"""Not requested. Built speculatively."""
def __init__(self, app_id: str, api_key: str) -> None:
self._app_id = app_id
self._api_key = api_key
def send(self, recipient: str, subject: str, body: str) -> bool:
print(f"[PUSH] → {recipient}: {subject}")
return True
class SlackSender(NotificationSender):
"""Not requested. Built speculatively."""
def __init__(self, webhook_url: str) -> None:
self._webhook_url = webhook_url
def send(self, recipient: str, subject: str, body: str) -> bool:
print(f"[SLACK] → {self._webhook_url}: {body}")
return True
class WebhookSender(NotificationSender):
"""Not requested. Built speculatively."""
def __init__(self, endpoint: str, secret: str) -> None:
self._endpoint = endpoint
self._secret = secret
def send(self, recipient: str, subject: str, body: str) -> bool:
print(f"[WEBHOOK] → {self._endpoint}: {body}")
return True
class NotificationService:
"""Registry + dispatcher for all the channels nobody asked for yet."""
def __init__(self) -> None:
self._senders: dict[NotificationChannel, NotificationSender] = {}
def register(self, channel: NotificationChannel, sender: NotificationSender) -> None:
self._senders[channel] = sender
def send(self, channel: NotificationChannel, recipient: str, subject: str, body: str) -> bool:
sender = self._senders.get(channel)
if not sender:
raise ValueError(f"No sender registered for channel: {channel}")
return sender.send(recipient, subject, body)
# Client must wire up a factory for 5 channels to use 1
service = NotificationService()
service.register(NotificationChannel.EMAIL, EmailSender("smtp.example.com", 587, "user", "pass"))
service.register(NotificationChannel.SMS, SmsSender("api_key", "+15550000"))
service.register(NotificationChannel.PUSH, PushSender("app_id", "api_key"))
service.register(NotificationChannel.SLACK, SlackSender("https://hooks.slack.com/..."))
service.register(NotificationChannel.WEBHOOK, WebhookSender("https://...", "secret"))
service.send(NotificationChannel.EMAIL, "jane@example.com", "Welcome!", "Thanks for joining.")
# The other four registrations are never called. Their code is maintained forever.
# ════════════════════════════════════════════════════════════════
# YAGNI COMPLIANT — Send email. That's all that was asked for.
# ════════════════════════════════════════════════════════════════
@dataclass
class SmtpConfig:
host: str
port: int
username: str
password: str
def send_email(
config: SmtpConfig,
recipient: str,
subject: str,
body: str,
) -> None:
"""Send a plain-text email. Raises on failure."""
msg = EmailMessage()
msg["From"] = config.username
msg["To"] = recipient
msg["Subject"] = subject
msg.set_content(body)
with smtplib.SMTP(config.host, config.port) as smtp:
smtp.starttls()
smtp.login(config.username, config.password)
smtp.send_message(msg)
# ── Client ─────────────────────────────────────────────────────
if __name__ == "__main__":
config = SmtpConfig("smtp.example.com", 587, "user@example.com", "password")
# Does exactly what was asked for.
send_email(config, "jane@example.com", "Welcome!", "Thanks for joining.")
# When SMS is genuinely needed: add an send_sms() function.
# At that point the SMS provider is known, the phone number format is known,
# the message length constraints are known — the implementation will be correct
# because it is built with real information, not speculation.
Production-Ready Example
The most instructive YAGNI challenge in production is payment processing — a system that currently needs to accept credit cards via one provider. The YAGNI violation builds a pluggable payment gateway abstraction, a provider registry, and an adaptor layer supporting four providers. The YAGNI solution integrates the one needed provider correctly and completely, leaving the door open for a second provider when it is actually requested.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional
import stripe # pip install stripe
# ════════════════════════════════════════════════════════════════
# YAGNI VIOLATION — Abstract gateway for 4 providers, 1 is used
# ════════════════════════════════════════════════════════════════
from abc import ABC, abstractmethod
from enum import Enum
class PaymentProvider(Enum):
STRIPE = "stripe"
PAYPAL = "paypal" # Not integrated — speculative
BRAINTREE = "braintree" # Not integrated — speculative
SQUARE = "square" # Not integrated — speculative
@dataclass
class PaymentResult:
success: bool
transaction_id: Optional[str]
error: Optional[str]
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount: Decimal, currency: str, token: str, description: str) -> PaymentResult: ...
@abstractmethod
def refund(self, transaction_id: str, amount: Optional[Decimal]) -> PaymentResult: ...
@abstractmethod
def get_transaction(self, transaction_id: str) -> dict: ...
class StripeGateway(PaymentGateway):
def __init__(self, api_key: str) -> None:
stripe.api_key = api_key
def charge(self, amount: Decimal, currency: str, token: str, description: str) -> PaymentResult:
try:
intent = stripe.PaymentIntent.create(
amount=int(amount * 100),
currency=currency,
payment_method=token,
description=description,
confirm=True,
)
return PaymentResult(True, intent.id, None)
except stripe.error.StripeError as e:
return PaymentResult(False, None, str(e))
def refund(self, transaction_id: str, amount: Optional[Decimal]) -> PaymentResult:
try:
params: dict = {"payment_intent": transaction_id}
if amount:
params["amount"] = int(amount * 100)
refund = stripe.Refund.create(**params)
return PaymentResult(True, refund.id, None)
except stripe.error.StripeError as e:
return PaymentResult(False, None, str(e))
def get_transaction(self, transaction_id: str) -> dict:
return stripe.PaymentIntent.retrieve(transaction_id)
class PayPalGateway(PaymentGateway):
"""Not requested. Stub implementation. Must be maintained."""
def __init__(self, client_id: str, secret: str) -> None:
self._client_id = client_id
self._secret = secret
def charge(self, amount, currency, token, description) -> PaymentResult:
raise NotImplementedError("PayPal not yet integrated")
def refund(self, transaction_id, amount) -> PaymentResult:
raise NotImplementedError("PayPal not yet integrated")
def get_transaction(self, transaction_id) -> dict:
raise NotImplementedError("PayPal not yet integrated")
class BraintreeGateway(PaymentGateway):
"""Not requested. Stub. Must be maintained."""
def __init__(self, merchant_id: str, public_key: str, private_key: str) -> None:
pass
def charge(self, amount, currency, token, description) -> PaymentResult:
raise NotImplementedError("Braintree not yet integrated")
def refund(self, transaction_id, amount) -> PaymentResult:
raise NotImplementedError("Braintree not yet integrated")
def get_transaction(self, transaction_id) -> dict:
raise NotImplementedError("Braintree not yet integrated")
class PaymentServiceViolation:
def __init__(self, gateway: PaymentGateway) -> None:
self._gateway = gateway
def process_payment(self, amount: Decimal, currency: str, token: str, description: str) -> PaymentResult:
return self._gateway.charge(amount, currency, token, description)
def process_refund(self, transaction_id: str, amount: Optional[Decimal] = None) -> PaymentResult:
return self._gateway.refund(transaction_id, amount)
# ════════════════════════════════════════════════════════════════
# YAGNI COMPLIANT — Stripe integration, complete and correct
# ════════════════════════════════════════════════════════════════
@dataclass(frozen=True)
class ChargeResult:
payment_intent_id: str
amount_charged: Decimal
currency: str
status: str
@dataclass(frozen=True)
class RefundResult:
refund_id: str
amount: Decimal
status: str
class StripePaymentService:
"""
Processes payments and refunds via Stripe.
When a second payment provider is requested, extract a PaymentGateway
protocol at that time — with real knowledge of what the second provider's
API requires. The abstraction will be accurate because it is informed by
two actual implementations, not one actual and three imagined ones.
"""
def __init__(self, api_key: str) -> None:
stripe.api_key = api_key
def charge(
self,
*,
amount_cents: int,
currency: str,
token: str,
description: str,
metadata: Optional[dict] = None,
) -> ChargeResult:
"""
Charges a payment method.
Args:
amount_cents: Amount in the smallest currency unit (cents for USD).
currency: ISO 4217 currency code (e.g., "usd").
token: Stripe PaymentMethod ID.
description: Human-readable charge description.
metadata: Optional key-value pairs attached to the charge.
Raises:
stripe.error.CardError: Card was declined.
stripe.error.StripeError: Other Stripe errors.
"""
intent = stripe.PaymentIntent.create(
amount=amount_cents,
currency=currency,
payment_method=token,
description=description,
metadata=metadata or {},
confirm=True,
return_url="https://example.com/payment/complete",
)
return ChargeResult(
payment_intent_id=intent.id,
amount_charged=Decimal(intent.amount) / 100,
currency=intent.currency,
status=intent.status,
)
def refund(
self,
*,
payment_intent_id: str,
amount_cents: Optional[int] = None,
reason: Optional[str] = None,
) -> RefundResult:
"""
Refunds a charge, fully or partially.
Args:
payment_intent_id: The PaymentIntent ID from the original charge.
amount_cents: Amount to refund in cents. None means full refund.
reason: One of "duplicate", "fraudulent", "requested_by_customer".
Raises:
stripe.error.StripeError: Refund failed.
"""
params: dict = {"payment_intent": payment_intent_id}
if amount_cents is not None:
params["amount"] = amount_cents
if reason:
params["reason"] = reason
refund_obj = stripe.Refund.create(**params)
return RefundResult(
refund_id=refund_obj.id,
amount=Decimal(refund_obj.amount) / 100,
status=refund_obj.status,
)
def retrieve_payment_intent(self, payment_intent_id: str) -> dict:
"""Returns the raw Stripe PaymentIntent object as a dict."""
return stripe.PaymentIntent.retrieve(payment_intent_id)
# ── Client ─────────────────────────────────────────────────────
if __name__ == "__main__":
payment_service = StripePaymentService(api_key="sk_test_...")
# Charge
try:
result = payment_service.charge(
amount_cents=4999,
currency="usd",
token="pm_card_visa",
description="Premium Plan — Monthly",
metadata={"user_id": "U001", "plan": "premium"},
)
print(f"Charged: ${result.amount_charged} — Intent: {result.payment_intent_id}")
except stripe.error.CardError as e:
print(f"Card declined: {e.user_message}")
# Refund
refund_result = payment_service.refund(
payment_intent_id="pi_...",
reason="requested_by_customer",
)
print(f"Refunded: ${refund_result.amount} — Status: {refund_result.status}")
# When PayPal is requested: create StripePaymentService → PaymentService protocol,
# add PayPalPaymentService. The protocol design will be informed by two real
# implementations — not pre-guessed from one.
Real-World Use Cases
YAGNI applies at every layer of a system — from individual function parameters to full architectural decisions.
API versioning: Adding /v2 endpoints before there is any breaking change to introduce is a YAGNI violation. The versioning machinery — routing, documentation, compatibility shims, test duplication — is real work that serves no user yet. Introduce versioning when a breaking change actually needs to be made, with a concrete migration timeline.
Database schema columns: Adding a notes TEXT column to every table "because we might want notes someday" multiplies speculative columns across the schema. Each empty column is storage, confusion, and a question in every code review ("Is this used? When is it set?"). Add columns when the feature that populates them is being built.
Configuration flags: A feature flag that enables or disables a feature that doesn't exist yet is a YAGNI violation. The flag adds complexity to the configuration system, the documentation, and the code that reads it — in service of a feature that may never be needed. Add flags when the feature they gate is being shipped.
Microservice decomposition: Decomposing a system into microservices before team size, scaling requirements, or independent deployment needs justify it adds enormous operational complexity. A monolith is not a problem to be solved — it is a simpler architecture. Decompose when the problems that microservices solve (independent scaling, independent deployment, team boundaries) actually arise.
Caching layers: Adding Redis before measuring whether the uncached system is too slow is a YAGNI violation. Redis adds operational complexity, a failure mode, a consistency risk, and on-call burden. Profile first. If the database is the bottleneck: add an index, optimize the query, or then evaluate caching.
Generic algorithms: Writing a generic sort function that handles any Comparable type when the only use case is sorting a list of users by last name is a YAGNI violation. The generic version is more complex to write, more complex to read, and — critically — the second use case may require a different signature anyway. Write the specific version; extract the generic version when the second use case is real.
Plugin systems: Building a plugin architecture for code that has one plugin and no committed roadmap for a second is a YAGNI violation. Plugin systems add real complexity — lifecycle management, API stability contracts, version compatibility — for a benefit that exists only in imagination. Build the plugin system when the second plugin is on the roadmap with a committed delivery date.
Language-Specific Mistakes and Anti-Patterns
❌ Mistake #1: Speculative Function Parameters
# BAD: Parameters added "in case we need them later"
def send_welcome_email(
user_email: str,
user_name: str,
template_id: str = "default", # ← Only "default" is ever used
cc_addresses: list = None, # ← Never used
attachment: str = None, # ← Never used
priority: str = "normal", # ← Never used
track_opens: bool = False, # ← Never used
custom_headers: dict = None, # ← Never used
) -> bool:
# Only user_email and user_name are actually needed
send_email(to=user_email, name=user_name)
return True
# GOOD: Parameters for the requirements that exist
def send_welcome_email(user_email: str, user_name: str) -> None:
"""Sends the welcome email template. Add parameters when new requirements land."""
send_email(to=user_email, name=user_name)
❌ Mistake #2: Speculative Base Classes
# BAD: Base class extracted in anticipation of a subclass that doesn't exist
class BaseReportGenerator:
"""Base for all report generators. Currently has one subclass."""
def generate(self, data: list) -> str:
raise NotImplementedError
def format_header(self) -> str:
raise NotImplementedError
def format_footer(self) -> str:
raise NotImplementedError
class PdfReportGenerator(BaseReportGenerator):
def generate(self, data: list) -> str: ...
def format_header(self) -> str: ...
def format_footer(self) -> str: ...
# Only PDF reports are needed. The base class serves one subclass.
# GOOD: A direct class. Extract the base class when the second report type is needed.
class PdfReportGenerator:
def generate(self, data: list) -> str:
header = self._format_header()
content = self._format_rows(data)
footer = self._format_footer()
return f"{header}\n{content}\n{footer}"
def _format_header(self) -> str: return "=== Report ==="
def _format_rows(self, data: list) -> str: return "\n".join(str(row) for row in data)
def _format_footer(self) -> str: return "=== End ==="
Frequently Asked Questions
Q1: Does YAGNI mean I should never think ahead?
No — YAGNI is specifically about implementation, not thought. You should absolutely think about how a system might evolve, what requirements might arise, and how architecture decisions today will constrain options tomorrow. The YAGNI discipline is: think about the future, but don't build for a future that hasn't been committed. The distinction between thinking ahead (always valuable) and building ahead (usually wasteful) is the core of YAGNI.
Good thinking ahead produces architecture that can be extended without major surgery when the future arrives. YAGNI-compliant architecture is designed to be changed, not designed to pre-implement the change.
Q2: How do I apply YAGNI when a PM asks me to "future-proof" the code?
First, understand what the PM means. "Future-proof" sometimes means "design it so we can change it easily later" — which is YAGNI-compatible. Designs that are easy to change are usually simpler, not more complex. Clean code, good naming, and clear separation of concerns are future-proof without being speculative.
"Future-proof" sometimes means "add the features for the roadmap items we haven't resourced yet" — which is a YAGNI violation. In this case, a productive conversation is: "I can design the architecture to make that easy to add when we're ready to build it. Building it now without the full requirements would mean we might build the wrong version."
If the future feature is committed — scheduled, resourced, with acceptance criteria — it may be appropriate to design for it now. If it is speculative ("we might want this someday"), YAGNI says: don't build it.
Q3: How does YAGNI interact with test infrastructure?
Test doubles (mocks, stubs, fakes) are a legitimate reason to add abstractions — but only when the test actually needs to substitute a real dependency. A UserRepository interface that allows a test to inject an InMemoryUserRepository is not a YAGNI violation if that test exists and uses it.
The YAGNI-violating version is adding the interface before the test exists, on the theory that tests will need it someday. Write the test. If the test needs an interface, add the interface. If it doesn't need one (perhaps because the database can be tested directly, or a test container provides a real database), don't add it.
Q4: Isn't it more expensive to add things later than to add them now?
Sometimes — but less often than intuition suggests. Adding a feature later costs more than adding it now in two specific conditions: (1) the architecture must be significantly restructured to accommodate it, or (2) many other systems have been built on top of the assumption that the feature doesn't exist, making the addition disruptive.
YAGNI says: keep the architecture clean and changeable enough that adding things later is cheap. The cost of adding later should be low for well-designed code. When developers say "it would have been cheaper to add this earlier," they usually mean "the code is not well-structured enough to be easily changed" — which is a KISS and architecture problem, not a YAGNI problem.
Q5: Does YAGNI apply to infrastructure choices (databases, queues, caches)?
Yes — infrastructure choices are among the most consequential YAGNI decisions because they are the hardest to reverse. Adopting Kubernetes, Kafka, or Redis before the requirements that justify them have been demonstrated adds real operational complexity and on-call burden with no current benefit.
The discipline: measure the actual problem before adding infrastructure to solve it. If the database is slow, profile the queries before adding Redis. If the system is overloaded, measure the load before adopting a message queue. Infrastructure components solve specific, measurable problems — adopt them when you have measured the problem, not when you imagine you might have it someday.
Q6: How does YAGNI affect API design?
API design is one of the most important YAGNI contexts because APIs are hard to change without breaking consumers. This creates a genuine tension: you want the API to be extensible, but you don't want to pre-build extensibility that may not be needed or may be built wrong.
The YAGNI discipline for APIs: design for backward compatibility, not for forward speculation. An API that accepts a JSON body with fields for current requirements can have new fields added later without breaking existing clients. Version the API when a breaking change is actually needed — not preemptively. Avoid adding optional parameters that aren't needed yet; they add ambiguity to the API contract ("what happens if I set this? What's the default behavior?").
Q7: What is the relationship between YAGNI and MVP (Minimum Viable Product)?
MVP and YAGNI are strongly aligned. An MVP is the smallest set of features that delivers genuine value to users and enables learning about what users actually need. YAGNI is the principle that prevents the MVP from accumulating features that were imagined but not validated.
The connection: YAGNI is how you build an MVP. Without YAGNI, an MVP expands with speculative features until it is no longer minimum. YAGNI is the discipline that keeps development focused on the features whose value has been validated — or will be validated in the immediate delivery — rather than the features that seem like they might be valuable.
Q8: How do I handle YAGNI when requirements are genuinely unclear?
When requirements are unclear, YAGNI recommends building the minimal implementation that can answer the questions the unclear requirements pose. A prototype or spike that explores the solution space is not a YAGNI violation — it is the YAGNI-compliant approach to uncertainty. The violation is committing a production implementation to a specific design before the requirements are clear.
When building in the presence of uncertainty, prefer reversible decisions over irreversible ones. A simple implementation that can be replaced is better than a complex framework that locks in a design. The simpler the implementation, the cheaper it is to discard when the requirements clarify into something different.
Q9: Can YAGNI lead to technical debt?
Yes, if it is misapplied. YAGNI says don't build features that aren't needed. It does not say build the features that are needed poorly. Technical debt arises from low-quality implementation — missing tests, unclear naming, tangled responsibilities — not from building only what is needed.
The misapplication: treating YAGNI as a license to skip tests, avoid refactoring, or write quick-and-dirty code "because we might change it anyway." That is not YAGNI — that is laziness. YAGNI means: build the right thing, build it well, and stop when the requirement is met. "Build it well" is not optional.
Q10: How does YAGNI apply to security and compliance requirements?
Security and compliance requirements are explicitly outside the scope of YAGNI. They are not speculative futures — they are constraints that must be satisfied from the beginning. GDPR compliance, HIPAA requirements, SOC 2 controls, accessibility standards — these apply to the system from the moment it handles data subject to them.
YAGNI's guidance here is different: address security and compliance requirements completely and correctly from the start, not speculatively but necessarily. The discipline is to address the security requirements that actually apply to the current system, not to pre-build security infrastructure for threat models that don't apply. A tool used internally by three developers has different security requirements than a public API handling financial data.
Key Takeaways
🎯 Core Concept: YAGNI (You Aren't Gonna Need It) is the principle that you should not implement something until it is actually needed. Every feature, abstraction, parameter, or infrastructure component that is built speculatively — in anticipation of a future need that has not been committed — imposes real, immediate costs (implementation, testing, maintenance, complexity) for a benefit that may never materialize, and that will often be wrong when the actual need arrives. YAGNI is the discipline of building for the requirements that exist, not the requirements that might exist.
🔑 Key Benefits:
- Eliminated waste: Every hour not spent on speculative code is an hour available for validated requirements
- Reduced complexity: Systems built without speculative features are simpler, smaller, and easier to understand
- Better designs when features arrive: Deferring a feature until it is needed means building it with full knowledge of the actual requirement — producing more accurate implementations than speculation allows
- Faster delivery: Focused development on actual requirements reduces time-to-value for each sprint
- Lower maintenance burden: Every line of speculative code must be maintained until it is either used or deleted; YAGNI keeps that burden minimal
- More reversible architecture: Simple, focused code is easier to change; speculative frameworks are harder to remove than they were to add
🌐 Language-Specific Highlights:
- Python: Avoid speculative function parameters with default values; extract base classes only when a second subclass is real; resist the temptation to build a class for what a function can do cleanly;
dataclassis sufficient until behavior warrants a full class - TypeScript: Don't pre-add generic type parameters — they add noise before they add value; extract interfaces when multiple implementations exist, not before; avoid building event bus infrastructure for direct function call use cases
- Java: Repository + Service layers earn their place; Manager, Facade, and Coordinator layers rarely do without explicit business logic to justify them; Spring beans are not free — only inject what the test or production code currently needs
- JavaScript: Local state (
useState) for local problems; global stores for genuinely shared state; avoid Redux, MobX, or Zustand until multiple components genuinely share state that needs to be synchronized - C#:
staticutility classes are YAGNI-compliant for stateless logic; avoid genericRepository<T>until the second entity repository is actually needed;record with { }is YAGNI-compliant for simple data — no builder pattern required - PHP: Avoid building a query builder when PDO with named methods is sufficient; service classes are YAGNI-compliant; service locators and abstract factories are rarely needed until dependency graphs become complex
- Go: Interfaces at the point of use, not the point of definition;
errorvalues are YAGNI-compliant — custom error types only when callers need to distinguish them; channels and goroutines only when concurrency is an actual requirement - Rust:
Box<dyn Trait>is a cost — introduce it when a second implementor is real; free functions over methods when there is no state to encapsulate;Result<T, Box<dyn Error>>is YAGNI-compliant until error types need to be distinguished - Dart: Top-level functions before class methods;
abstract classonly when multiple implementations exist; BLoC/Cubit are YAGNI-compliant when state management complexity is real —setStatefor simple cases - Swift: Protocols before concrete types only when needed for testing or multiple implementations;
structbeforeclass— structs are simpler by default; avoid Combine pipelines whereasync/awaitwith a direct call is sufficient - Kotlin:
data classbefore sealed classes; sealed classes before enum classes — choose the simplest representation for the states that actually exist;objectfor singletons only when shared global state is genuinely needed
📋 When YAGNI Applies:
- Any time you hear "we might need this later"
- Any time a function parameter is added for a use case that doesn't exist yet
- Any time an interface is extracted for a second implementation that is purely hypothetical
- Any time infrastructure (cache, queue, microservice) is added before the problem it solves has been measured
- Any time a feature is built from a roadmap item without a committed delivery date and acceptance criteria
⚠️ When YAGNI Does NOT Apply:
- Security and compliance requirements that apply to the current system
- Architectural decisions whose cost of reversal is genuinely high (database choice, communication protocol)
- Known, committed, resourced requirements scheduled for the near term
- Refactoring that serves current duplication (DRY) — not speculative future use
- Code quality investments (tests, naming, structure) that make all future changes cheaper
🛠️ Best Practices Across Languages:
- Build for the ticket, not the roadmap: Implement what the current user story requires; let future stories drive future code
- Leave the door open, don't build the room: Design architecture to be changeable, but don't pre-implement the changes
- Commit to reversibility: Prefer simpler implementations that can be changed over complex frameworks that cannot be easily undone
- Measure before adding infrastructure: Profile before caching; load test before queuing; observe before distributing
- Defer abstraction until it is earned: An interface earned by two implementations is accurate; an interface imagined for one implementation is speculative
- Delete the speculative code you find: If code was added speculatively and has never been called, delete it; it serves the codebase better as a deletion than as maintenance debt
💡 Common Pitfalls:
- Confusing thoroughness with YAGNI violations: Adding good tests, clear documentation, and proper error handling for existing features is thoroughness, not YAGNI violation; adding features that weren't asked for is
- "It's only a few lines": The implementation cost is not the only cost; every speculative addition has a perpetual maintenance cost and a cognitive load cost for every future reader
- "The requirements will definitely change": Requirements always change — in unpredictable directions. Pre-building for one predicted change often makes the actual change harder, not easier
- Using YAGNI to skip quality: YAGNI is about scope, not quality; it says don't build extra features, not don't build features well
🎁 Advanced Perspective:
- YAGNI and the cost of the wrong abstraction: The wrong abstraction — built speculatively and then used throughout the codebase — is more expensive to remove than no abstraction at all. "Waiting until we have two actual use cases" is not laziness; it is the only way to build an abstraction that accurately fits both cases
- YAGNI and Agile: YAGNI is a core Agile discipline because Agile development acknowledges that requirements will change and that early certainty is low. Deferring implementation to the sprint when a feature is needed ensures implementation is based on maximum available information
- The compounding benefit: Every team that consistently applies YAGNI has a smaller codebase than one that doesn't — for the same delivered features. A smaller codebase is faster to build on, easier to understand, cheaper to test, and simpler to deploy. The benefit compounds with every sprint
The deepest insight behind YAGNI is this: the best code for a future requirement is the code written when that requirement is real, with full knowledge of what it actually needs. Speculative code, written without that knowledge, is almost always a worse implementation than the code that would have been written with it. YAGNI is not a constraint on ambition — it is a commitment to building the right thing at the right time.
Want to dive deeper into software design principles? Check out our comprehensive guides on:
- KISS Principle (Keep It Simple, Stupid)
- DRY Principle (Don't Repeat Yourself)
- WET Principle (Write Everything Twice)
- OAOO Principle (Once And Only Once)
- SSOT Principle (Single Source of Truth) Each guide includes examples in all 11 programming languages with language-specific best practices.