YAGNI — You Aren’t Gonna Need It #
There’s a very common mindset among engineers who’ve read many architecture books: “It will definitely grow later, we’d better prepare for it from the start.” The result is a system with only five endpoints but already using microservices, Kafka, and CQRS — because “we’ll definitely need it”. Or a struct with twenty filter fields when the endpoint only needs one. Or a payment service with layered interfaces for providers that don’t have contracts yet. This code isn’t free: it takes time to write, time to understand, time to maintain, and often never actually gets used. YAGNI — You Aren’t Gonna Need It — is the principle that attacks this mindset directly: don’t implement something before it’s truly needed. It’s not anti-planning, not anti-architecture — it’s anti-over-assumption about a future that hasn’t happened.
What Is YAGNI? #
YAGNI comes from Extreme Programming (XP) practice, popularized by Ron Jeffries. Its definition is simple but often misunderstood:
Don’t implement something before it’s truly needed.
The keyword that’s often missed is “truly” — not “maybe later”, not “the PM said next month”, not “systems like this usually need it”. YAGNI is about needs that have written requirements, concrete use cases, and stakeholder validation that this is indeed a priority.
YAGNI is not a ban on thinking ahead. There’s an important difference to understand:
YAGNI FORBIDS: YAGNI DOES NOT FORBID:
───────────────────────────────────── ────────────────────────────────────
Implementing features that have Small, clear interfaces for
no requirements yet existing dependencies
Multi-provider abstractions when Code structure that's easy to
there's only one concrete provider change without a big rewrite
Fields or parameters that Complete error handling for
"might be useful later" existing paths
Architecture layers for scale Logging and observability that
with no indicator it will be are always needed
reached
Extensibility config for features Security controls that are
not on the roadmap mandatory requirements
YAGNI and extensible design can go hand in hand. What’s forbidden is implementing speculative features — not designing for easy extension when the time comes.
flowchart TD
Q1{"Is there a written\nrequirement?"}
Q2{"Is there a concrete\nuse case now?"}
Q3{"Is the cost of NOT\nbuilding it now\nbigger than\nbuilding it?"}
BUILD["Build now ✓"]
DEFER["Defer — YAGNI ✓"]
DESIGN["Design for easy\nextension,\nbut don't\nimplement yet"]
Q1 -->|Yes| Q2
Q1 -->|No| DEFER
Q2 -->|Yes| BUILD
Q2 -->|No| Q3
Q3 -->|Yes| BUILD
Q3 -->|No| DESIGN
style BUILD fill:#5CB85C,color:#fff
style DEFER fill:#4C9BE8,color:#fff
style DESIGN fill:#F0AD4E,color:#fffFour Real Problems with Unneeded Code #
Speculative code feels like an investment but behaves like debt. These are the four real costs it creates:
1. The cost of writing time that produces no value now. Every hour spent on an unneeded feature is an hour not spent on features already in the backlog with real users. In a competitive sprint, this is a real trade-off.
2. The recurring cost of understanding. Code in a codebase must be understood by every engineer who reads it — now and in the future. Layered abstractions for something unused slow down onboarding and increase cognitive load during debugging.
3. The unexpected maintenance cost. Every line of code is surface area for bugs. Code written for speculative features still needs updating during refactors, dependency changes, and migrations. Code that provides no value but still requires maintenance is the definition of technical debt.
4. The cost of wrong assumptions. The most dangerous: features built on assumptions about the future often don’t match the future that actually happens. When the requirement finally arrives, it looks different from what was assumed, and the abstractions already built become obstacles instead of aids.
YAGNI at the Struct and Field Level #
The most common YAGNI violation — and the easiest to slip into a codebase undetected in code review — is adding fields or parameters that “might be useful later”.
// ANTI-PATTERN: a filter struct with every possible field
// when the endpoint only needs filtering by name
type UserFilter struct {
Name *string // ← used now
Email *string // ← "we'll definitely need it"
Age *int // ← "maybe for segmentation"
Gender *string // ← "in case of personalization"
City *string // ← "for regional targeting"
Country *string // ← "in case we expand abroad"
IsActive *bool // ← "for admin filtering"
IsVerified *bool // ← "in case there's verification"
CreatedFrom *time.Time // ← "for reports"
CreatedTo *time.Time // ← "for reports"
Tags []string // ← "in case of a tagging system"
}
// Problems:
// - Every *string field needs a nil check in the query builder
// - API docs become confusing — which fields actually work?
// - Tests must cover combinations that may never be used
// - If the database schema changes, all these fields become noise
// CORRECT: only fields with requirements today
type UserFilter struct {
Name string // the only filter currently needed
}
// When a new requirement for email filtering arrives:
// 1. Add the Email field to the struct
// 2. Update the query builder
// 3. Update the documentation
// This change is easy and isolated — nothing breaks
type UserFilter struct {
Name string
Email string
}
The same rule applies to function parameters:
// ANTI-PATTERN: optional parameters for every possible behavior
func CreateUser(
name string,
email string,
role string, // ← "we'll definitely need roles"
sendWelcomeEmail bool, // ← "maybe some don't want emails"
skipValidation bool, // ← "for testing / admin"
notifySlack bool, // ← "in case of Slack integration"
auditLog bool, // ← "for compliance"
) error {
// Every boolean parameter doubles the number of paths to test
// 4 booleans = 16 combinations theoretically needing tests
}
// CORRECT: only parameters with needs today
func CreateUser(name, email string) error {
// Simple, clear, easy to test
// When roles are needed: add the parameter then
// When welcome emails are needed: add when the email feature is designed
}
YAGNI at the Service and Abstraction Level #
The most classic YAGNI case is multi-provider abstraction before a second provider exists.
// ANTI-PATTERN: architecture for a future that doesn't exist
// Built because "there will definitely be many payment providers"
type PaymentProvider interface {
Pay(ctx context.Context, amount int64, currency string) error
Refund(ctx context.Context, txID string, amount int64) error
Validate(ctx context.Context, txID string) (*PaymentStatus, error)
GetWebhookSecret() string
HandleWebhook(ctx context.Context, payload []byte) error
}
type PaymentProviderFactory interface {
Create(providerType string, cfg ProviderConfig) (PaymentProvider, error)
}
type providerRegistry struct {
mu sync.RWMutex
providers map[string]PaymentProvider
}
func (r *providerRegistry) Register(name string, p PaymentProvider) {
r.mu.Lock()
defer r.mu.Unlock()
r.providers[name] = p
}
type PaymentService struct {
registry *providerRegistry
factory PaymentProviderFactory
}
// All of this for a single provider — Midtrans
// The refund feature doesn't even have requirements yet
// The Validate and HandleWebhook interfaces are never called from anywhere
// CORRECT: matching real needs — one provider, one operation that exists
type PaymentService struct {
midtrans *midtrans.Client
}
func NewPaymentService(client *midtrans.Client) *PaymentService {
return &PaymentService{midtrans: client}
}
func (s *PaymentService) CreateCharge(ctx context.Context, req ChargeRequest) (*ChargeResult, error) {
resp, err := s.midtrans.CreateTransaction(ctx, &midtrans.TransactionRequest{
OrderID: req.OrderID,
GrossAmount: req.Amount,
})
if err != nil {
return nil, fmt.Errorf("createCharge %s: midtrans: %w", req.OrderID, err)
}
return &ChargeResult{TransactionID: resp.TransactionID, PaymentURL: resp.RedirectURL}, nil
}
When the second provider need truly arrives, the refactor is clear and focused:
// Healthy YAGNI: refactor WHEN there's a real requirement
// Trigger: Product decides to support Xendit for certain merchants
// Step 1: extract an interface based on operations that actually exist
type PaymentGateway interface {
CreateCharge(ctx context.Context, req ChargeRequest) (*ChargeResult, error)
}
// Step 2: wrap the existing implementation
type MidtransGateway struct{ client *midtrans.Client }
func (g *MidtransGateway) CreateCharge(ctx context.Context, req ChargeRequest) (*ChargeResult, error) {
// existing implementation, moved here
}
// Step 3: add the new implementation
type XenditGateway struct{ client *xendit.Client }
func (g *XenditGateway) CreateCharge(ctx context.Context, req ChargeRequest) (*ChargeResult, error) {
// new implementation
}
// Step 4: update PaymentService to accept the interface
type PaymentService struct {
gateway PaymentGateway // now this abstraction has real value
}
This refactor is much easier because the interface is shaped by real needs — not by assumptions about what “might be needed”. Interfaces emerging from refactors like this tend to be more accurate and smaller than ones designed up front.
sequenceDiagram
participant REQ as Requirement Arrives
participant NOW as Current Code
participant REF as Refactor
participant NEW as New Implementation
Note over REQ,NEW: The Healthy YAGNI Flow
REQ->>NOW: "Build payment — Midtrans first"
NOW->>NOW: PaymentService with *midtrans.Client directly
Note over NOW: Simple, working, easy to test
REQ->>REF: "Need Xendit support for enterprise merchants"
REF->>REF: Extract a PaymentGateway interface
REF->>REF: Wrap MidtransGateway
REF->>NEW: Create XenditGateway
Note over REF,NEW: Focused refactor, on-target interfaceYAGNI at the API Design Level #
YAGNI in API design means building endpoints only for existing use cases, with response shapes matching current needs — not with every field that “might be useful”.
// ANTI-PATTERN: a response with every field clients "might need"
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"` // "there will be role-based UI"
Permissions []string `json:"permissions"` // "in case of fine-grained access"
LastLoginAt *time.Time `json:"last_login_at"` // "for security audits"
LoginCount int `json:"login_count"` // "for analytics"
ProfileScore float64 `json:"profile_score"` // "for the recommendation engine"
Tags []string `json:"tags"` // "in case of tagging"
Metadata map[string]interface{} `json:"metadata"` // "for custom fields"
// 10 more fields never read by any client
}
// Problems:
// - Every field is a contract that must keep compatibility
// - Auto-generated clients become bloated
// - Database schema changes have wide impact because many fields are exposed
// - Unclear which fields clients actually use
// CORRECT: only fields current clients need
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// When there's a real need: add a field, version the API, or create a new endpoint
// Adding a field to a response is easier than removing one (breaking change)
YAGNI in API design also means not creating endpoints with no users yet:
// ANTI-PATTERN: endpoints for features that don't exist
router.GET("/api/v1/users/:id/recommendations", handleRecommendations) // no ML model
router.GET("/api/v1/users/:id/social-score", handleSocialScore) // no such concept
router.POST("/api/v1/users/:id/import", handleBulkImport) // no UI for this
router.GET("/api/v1/analytics/cohort", handleCohortAnalysis) // no requirements
// CORRECT: only endpoints with users today
router.POST("/api/v1/users", handleCreateUser)
router.GET("/api/v1/users/:id", handleGetUser)
router.PUT("/api/v1/users/:id", handleUpdateUser)
YAGNI at the Database Schema Level #
The most expensive long-term YAGNI violations often happen in database schemas — because database migrations are far more costly than code refactors.
-- ANTI-PATTERN: a table with speculative columns
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
-- columns with current requirements end here --
role VARCHAR(50), -- "there will be RBAC"
tier VARCHAR(20), -- "there will be a tier system"
score DECIMAL(5,2), -- "for gamification"
referral_code VARCHAR(20), -- "there will be a referral program"
affiliate_id UUID, -- "could become affiliates"
last_login_at TIMESTAMP, -- "for the security dashboard"
login_count INTEGER DEFAULT 0, -- "for analytics"
extra_data JSONB, -- "catch-all for the future"
-- 8 columns never filled, all NULL, present in every query
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Problems:
-- Every INSERT must include or skip these columns
-- Index planning becomes complex with many NULL columns
-- JOINs and queries feel heavier because of large row sizes
-- Schema evolution gets confusing: which columns are "active"?
-- CORRECT: only columns with needs today
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- When RBAC is needed: a migration adds the role column
-- ALTER TABLE users ADD COLUMN role VARCHAR(50) NOT NULL DEFAULT 'user';
-- This migration is easy, isolated, and rollbackable
YAGNI at the System Architecture Level #
YAGNI violations at the architecture level are the most visible and the most debated at project start. The “monolith or microservices?” question is almost always answered wrong when YAGNI is ignored.
ANTI-PATTERN: Architecture for scale that doesn't exist
A new app, 5 endpoints, 3 engineers, no users yet — but:
✗ Microservices because "we'll definitely need per-service scaling"
✗ Kafka because "we'll definitely need async processing"
✗ CQRS + Event Sourcing because "we'll need an audit trail"
✗ Redis Cluster because "traffic will definitely be huge"
✗ Kubernetes with HPA because "we'll need auto-scaling"
Real costs:
- Infrastructure setup time: 2–4 weeks
- Distributed system debugging complexity
- Cloud costs disproportionate to traffic
- Network overhead between services for every request
- Engineer onboarding 3x longer
CORRECT: Start with architecture matching the current stage
✓ Modular monolith — easy to split if truly needed
✓ Simple REST API — add async when there's a real bottleneck
✓ Single database — split when there's a real isolation need
✓ Single instance — scale horizontally when there's real traffic
✓ Deployable to one VM — containerize when there's a real deployment need
flowchart LR
subgraph YAGNI["Evolution Following YAGNI"]
direction TB
S1["Stage 1\nMonolith + single DB\n(startup, product validation)"]
S2["Stage 2\nMonolith + read replica\n(traffic starting to rise)"]
S3["Stage 3\nModular monolith\n+ a few separate services\n(team growing)"]
S4["Stage 4\nMicroservices for\nclear boundaries\n(different scaling per domain)"]
S1 -->|"traffic rises,\nprofiling shows\na real bottleneck"| S2
S2 -->|"team > 15 people,\ndeployment conflicts\nstart happening"| S3
S3 -->|"scaling requirements\nper domain differ\nsignificantly"| S4
end
style S1 fill:#5CB85C,color:#fff
style S2 fill:#4C9BE8,color:#fff
style S3 fill:#F0AD4E,color:#fff
style S4 fill:#9B59B6,color:#fffEvery transition is triggered by a measurable real need — not anticipation. The cost of migrating from monolith to microservices exists, but it’s far smaller than the cost of maintaining an architecture far too complex for current needs.
When Not to Apply YAGNI Extremely #
YAGNI isn’t an excuse to skip things that are inherently always needed. There’s a category of code that is never speculative, no matter how early the stage.
ALWAYS NEEDED — NOT A YAGNI VIOLATION:
Security:
✓ Input validation — always needed, not "later"
✓ SQL injection prevention — from the first line of code
✓ Authentication for endpoints that need auth
✓ Rate limiting for public endpoints
Reliability:
✓ Proper error handling — including edge cases
✓ Timeouts for all external calls
✓ Context cancellation for long-running operations
✓ Graceful shutdown
Observability:
✓ Logging for errors and important operations
✓ Health check endpoints
✓ Basic metrics for monitoring
Data integrity:
✓ Database constraints (NOT NULL, UNIQUE, FK) matching the schema
✓ Transactions for multi-step operations
✓ Idempotency for operations that can be retried
// THIS IS NOT YAGNI — always needed, even in the first sprint:
func CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
// Input validation — NOT speculative
if req.UserID == "" {
return nil, errors.New("user_id is required")
}
if req.Amount <= 0 {
return nil, errors.New("amount must be positive")
}
// Timeout for external calls — NOT speculative
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Transaction for multi-step operations — NOT speculative
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("createOrder: begin tx: %w", err)
}
defer tx.Rollback()
order, err := orderRepo.SaveTx(ctx, tx, req)
if err != nil {
return nil, fmt.Errorf("createOrder: save order: %w", err)
}
if err := inventoryRepo.DeductTx(ctx, tx, req.Items); err != nil {
return nil, fmt.Errorf("createOrder: deduct inventory: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("createOrder: commit: %w", err)
}
// Logging for important operations — NOT speculative
slog.Info("order created", "order_id", order.ID, "user_id", req.UserID)
return order, nil
}
How to distinguish “always needed” from “speculative”: is this code required for the system to function correctly and safely in its current use case? If yes — it’s not YAGNI, it’s mandatory.
Healthy Refactoring as a Substitute for Anticipation #
YAGNI doesn’t mean code never changes. Quite the opposite — YAGNI encourages refactoring that responds to real needs, not preventive refactoring based on assumptions. The healthy refactoring pattern:
1. WRITE the simplest thing that works:
→ Direct, concrete implementation, no premature abstraction
2. VALIDATE that it works:
→ Unit tests, integration tests, or user validation
3. IDENTIFY real needs that emerge:
→ Concrete new requirements, not assumptions
→ Real duplication needing elimination (DRY)
→ Measured bottlenecks, not guessed ones
4. REFACTOR with a clear purpose:
→ Extract an interface when a real second implementation exists
→ Add abstraction when there's knowledge repetition
→ Split services when there's a measured scaling bottleneck
5. REPEAT
What NOT to do:
→ Preventive refactors "so it's easier to change later" without concrete needs
→ Adding abstractions "for flexibility" without an existing use case
→ Generalizing for types that don't exist
YAGNI’s Relationship with Other Principles #
flowchart TD
YAGNI["YAGNI\n(You Aren't Gonna Need It)"]
KISS["KISS\nDon't add unnecessary\ncomplexity"]
DRY["DRY\nAbstract only knowledge\nthat truly repeats"]
SOLID["SOLID\nUse patterns only\nwhen there's a real need"]
AGILE["Agile / XP\nDeliver value now,\niterate on feedback"]
YAGNI -->|"reinforces"| KISS
YAGNI -->|"prevents premature\nabstraction in"| DRY
YAGNI -->|"prevents over-application\nof"| SOLID
YAGNI -->|"originates from and\nsupports"| AGILE
style YAGNI fill:#4C9BE8,color:#fff
style KISS fill:#5CB85C,color:#fff
style DRY fill:#5CB85C,color:#fff
style SOLID fill:#5CB85C,color:#fff
style AGILE fill:#F0AD4E,color:#fffYAGNI’s relationship with DRY deserves special attention. DRY pushes toward abstraction to eliminate duplication — but DRY abstractions made too early can become YAGNI violations. The solution is the Rule of Three: let code stay duplicated until there are three occurrences, then consider abstraction. Two occurrences might just be coincidental similarity.
YAGNI also doesn’t conflict with SOLID. Apply SOLID when there’s a real need it solves — interfaces for dependencies that will indeed have other implementations, OCP for behavior that will indeed be extended. Don’t apply SOLID as a ritual.
Anti-Patterns at a Glance #
// ✗ Fields that "might be useful later"
type CreateUserRequest struct {
Name string
Email string
Role string // no RBAC yet
ReferralCode string // no referral system yet
AffiliateSource string // no affiliate tracking yet
}
// ✗ Multi-provider interface for one provider
type NotificationProvider interface {
SendEmail(to, subject, body string) error
SendSMS(to, message string) error // no SMS provider yet
SendPush(token, title, body string) error // no push notifications yet
SendWhatsApp(to, message string) error // no WhatsApp yet
}
// Only email exists now — the other three methods are never called
// ✗ Boolean parameters for every possible behavior
func SendEmail(
to, subject, body string,
attachPDF bool, // no PDF feature yet
trackOpen bool, // no email tracking yet
useTemplate bool, // no template engine yet
) error {}
// ✗ Architecture for scale that doesn't exist
// Microservices + Kafka + Redis Cluster for an app with 10 active users
// ✗ Extensibility config without a use case
type PluginConfig struct {
Enabled bool
MaxPlugins int
PluginPaths []string
HookPoints []string
}
// Not a single plugin exists or is concretely planned
// ✗ Speculative database columns
// score DECIMAL, referral_code VARCHAR, tier VARCHAR
// — all NULL in every row, never read
YAGNI Checklist Before Commit #
BEFORE ADDING A FEATURE OR ABSTRACTION:
□ Is there a clear written requirement for this?
□ Is there a concrete use case happening today?
□ Is there a stakeholder explicitly requesting this?
□ If not built now, are any users affected?
FOR INTERFACES AND ABSTRACTIONS:
□ Is there more than one real implementation (or on a concrete roadmap)?
□ Is there a testing use case needing a mock of this interface?
□ Does this interface close a boundary that truly needs isolation?
FOR DATABASE SCHEMAS:
□ Does every new column have a query that reads it?
□ No "just in case" columns?
□ Nullable columns aren't used as a workaround for an unclear schema?
FOR ARCHITECTURE:
□ Is the architecture complexity proportional to the current problem?
□ Is there a real metric or indicator justifying this complexity?
□ Can it be explained to a new engineer in <10 minutes?
EXCEPTIONS (YAGNI doesn't apply):
□ Security controls — always mandatory
□ Error handling and timeouts — not speculative
□ Logging for important operations — not speculative
□ Data integrity constraints — not speculative
Summary #
- YAGNI isn’t anti-planning — it’s anti-over-assumption about a future that hasn’t happened. The key difference: extensible design (design that’s easy to change) is allowed; implementing speculative features is not.
- Four costs of unneeded code: writing time producing no value, recurring understanding costs for every reader, ongoing maintenance costs, and the risk of assumptions turning out wrong.
- At the struct and field level: add fields or parameters only when there’s a real requirement. Every “just in case”
*stringor*boolfield is a contract to maintain and surface area for bugs.- At the service and abstraction level: start concrete with one implementation. Extract an interface only when there’s a real need for a second implementation or for mocking in tests. Interfaces born from refactors tend to be more accurate than ones designed up front.
- At the API level: response shapes and endpoints only for existing use cases. Adding a field to a response is easier than removing one (breaking change).
- At the database schema level: speculative columns are the most expensive debt because database migrations are costly. Only create columns that have queries reading them.
- At the architecture level: start with architecture matching the current stage — modular monolith before microservices, single database before sharding. Raise complexity driven by real metrics, not anticipation.
- YAGNI doesn’t apply to: security controls, error handling and timeouts, logging for important operations, and data integrity constraints — these aren’t speculative, they’re always needed.
- Responsive refactoring is healthier than anticipation: write the simplest thing, validate it works, refactor when a real need emerges. “Build for today’s needs, design for easy change tomorrow.”