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:#fff

Four 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 interface

YAGNI 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:#fff

Every 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:#fff

YAGNI’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” *string or *bool field 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.”

← Previous: KISS   Next: SRP →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact