YAGNI Principle: Stop Building What You Don't Need
The Feature Nobody Used
I once worked on a team that spent three months building a sophisticated reporting system. It had:
- Custom report builder with drag-and-drop
- 47 different chart types
- Scheduled email delivery
- Export to 12 different formats
- Real-time collaborative editing
- AI-powered insights
We were proud of our engineering. It was beautiful, flexible, and powerful.
One year later, we checked the analytics:
- Total users of the feature: 3
- Reports created: 7
- Most used export format: PDF (one of the original 2 formats)
- Chart types used: Bar and Line (2 out of 47)
We had spent three months building something almost nobody wanted. That time could have been spent on features users were actually requesting.
This is what happens when you ignore YAGNI.
What is YAGNI?
YAGNI stands for "You Aren't Gonna Need It."
It's a principle from Extreme Programming (XP) that states:
"Always implement things when you actually need them, never when you just foresee that you need them."
In other words: Don't build features for imaginary future requirements.

Design principles diagram 1
Why We Over-Engineer
Before we dive into solutions, let's understand why we violate YAGNI so often:
1. The Fear of Future Changes
"What if we need to support multiple databases later?"
So we build an abstract database layer with pluggable adapters, factory patterns, and configuration systems... for an app that will only ever use PostgreSQL.
2. The Resume-Driven Development
"This would be a great opportunity to use Kubernetes, microservices, and event sourcing!"
So we architect a distributed system for an app with 100 users that could run perfectly on a single $5 server.
3. The "Just In Case" Mentality
"Just in case we need to scale to millions of users..."
So we build for scale before we have 100 users, wasting months on problems we may never have.
4. The Perfectionist Trap
"The code should be beautiful and handle every edge case..."
So we spend weeks polishing features instead of shipping and learning from real users.

Design principles diagram 2
Real Examples of YAGNI Violations
Example 1: The Premature Plugin System
go// Filename: bad_plugin_system.go // YAGNI VIOLATION: Building a plugin system for one implementation package main import ( "fmt" "reflect" ) // ============================================================================ // This is MASSIVE over-engineering for a simple notification need! // ============================================================================ // PluginMetadata describes a plugin type PluginMetadata struct { Name string Version string Author string Description string Hooks []string } // Plugin is the interface all plugins must implement type Plugin interface { GetMetadata() PluginMetadata Initialize(config map[string]interface{}) error Execute(context PluginContext) (PluginResult, error) Cleanup() error } // PluginContext provides context to plugins type PluginContext struct { RequestID string UserID string Data map[string]interface{} Extensions map[string]Plugin } // PluginResult contains the result of plugin execution type PluginResult struct { Success bool Data interface{} Errors []error } // PluginRegistry manages plugin registration and lifecycle type PluginRegistry struct { plugins map[string]Plugin hooks map[string][]Plugin order []string middleware []PluginMiddleware } // PluginMiddleware wraps plugin execution type PluginMiddleware func(Plugin, PluginContext) PluginResult // PluginLoader loads plugins dynamically type PluginLoader struct { searchPaths []string cache map[string]Plugin } // ... 500 more lines of plugin infrastructure ... // EmailNotificationPlugin is our ONE notification type! // All this infrastructure for ONE plugin! type EmailNotificationPlugin struct{} func (e *EmailNotificationPlugin) GetMetadata() PluginMetadata { return PluginMetadata{ Name: "email", Version: "1.0.0", } } func (e *EmailNotificationPlugin) Initialize(config map[string]interface{}) error { return nil } func (e *EmailNotificationPlugin) Execute(ctx PluginContext) (PluginResult, error) { // Finally, the actual work - send an email fmt.Println("Sending email...") return PluginResult{Success: true}, nil } func (e *EmailNotificationPlugin) Cleanup() error { return nil } // All we needed was to send an email!
Here's the YAGNI-compliant version:
go// Filename: good_simple_notification.go // YAGNI COMPLIANT: Build only what you need now package main import ( "fmt" "net/smtp" ) // ============================================================================= // SIMPLE SOLUTION: Just send the email! // ============================================================================= // EmailConfig holds email configuration type EmailConfig struct { SMTPHost string SMTPPort string From string Password string } // SendEmail sends an email // Why: This is ALL we need right now // Why: If we need SMS later, we add it THEN, not now func SendEmail(config EmailConfig, to, subject, body string) error { auth := smtp.PlainAuth("", config.From, config.Password, config.SMTPHost) msg := fmt.Sprintf("Subject: %s\r\n\r\n%s", subject, body) addr := fmt.Sprintf("%s:%s", config.SMTPHost, config.SMTPPort) return smtp.SendMail(addr, auth, config.From, []string{to}, []byte(msg)) } // NotifyUser sends a notification email to a user func NotifyUser(config EmailConfig, userEmail, message string) error { return SendEmail(config, userEmail, "Notification", message) } func main() { config := EmailConfig{ SMTPHost: "smtp.example.com", SMTPPort: "587", From: "noreply@example.com", Password: "secret", } // Simple and direct! err := NotifyUser(config, "user@example.com", "Your order has shipped!") if err != nil { fmt.Printf("Failed to send: %v\n", err) } else { fmt.Println("Notification sent!") } // Total lines of code: ~30 // vs Plugin System: ~500+ // Time to implement: 30 minutes vs 2 days }
Example 2: The Premature Abstraction
go// Filename: bad_premature_abstraction.go // YAGNI VIOLATION: Abstracting before you have multiple implementations package main // IUserRepository defines user data operations type IUserRepository interface { Save(user User) error FindByID(id int) (User, error) FindByEmail(email string) (User, error) FindAll() ([]User, error) Update(user User) error Delete(id int) error } // IUserService defines user business logic type IUserService interface { Register(name, email, password string) (User, error) Authenticate(email, password string) (User, error) UpdateProfile(id int, name string) error ChangePassword(id int, oldPass, newPass string) error } // IPasswordHasher defines password hashing type IPasswordHasher interface { Hash(password string) (string, error) Verify(hash, password string) bool } // IEmailValidator defines email validation type IEmailValidator interface { Validate(email string) error } // IUserFactory creates users type IUserFactory interface { CreateUser(name, email, password string) User } // UserRepositoryImpl is the ONLY implementation type UserRepositoryImpl struct { // We only use PostgreSQL, we only WILL use PostgreSQL } // UserServiceImpl is the ONLY implementation type UserServiceImpl struct { repo IUserRepository hasher IPasswordHasher validate IEmailValidator factory IUserFactory } // We have 5 interfaces but only ONE implementation of each! // All this ceremony for nothing!
The YAGNI version:
go// Filename: good_simple_user.go // YAGNI COMPLIANT: Concrete implementation, interface when needed package main import ( "crypto/sha256" "encoding/hex" "fmt" "regexp" "sync" ) // ============================================================================= // SIMPLE SOLUTION: Concrete types, add interfaces ONLY when you need them // ============================================================================= // User represents a user type User struct { ID int Name string Email string PasswordHash string } // UserStore manages users (concrete implementation) // Why: We only have one storage backend (in-memory for this example) // Why: If we need PostgreSQL, we refactor THEN type UserStore struct { users map[int]User nextID int mu sync.RWMutex } // NewUserStore creates a new user store func NewUserStore() *UserStore { return &UserStore{ users: make(map[int]User), nextID: 1, } } // Save saves a user func (s *UserStore) Save(user *User) error { s.mu.Lock() defer s.mu.Unlock() user.ID = s.nextID s.users[user.ID] = *user s.nextID++ return nil } // FindByID finds a user by ID func (s *UserStore) FindByID(id int) (User, bool) { s.mu.RLock() defer s.mu.RUnlock() user, exists := s.users[id] return user, exists } // FindByEmail finds a user by email func (s *UserStore) FindByEmail(email string) (User, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, user := range s.users { if user.Email == email { return user, true } } return User{}, false } // hashPassword hashes a password (simple, just a function) // Why: No interface needed - we only have one way to hash passwords func hashPassword(password string) string { hash := sha256.Sum256([]byte(password)) return hex.EncodeToString(hash[:]) } // validateEmail validates an email (simple, just a function) // Why: No interface needed - email validation doesn't need polymorphism func validateEmail(email string) error { pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` if matched, _ := regexp.MatchString(pattern, email); !matched { return fmt.Errorf("invalid email format") } return nil } // RegisterUser registers a new user // Why: Simple function, not a "service" with injected dependencies func RegisterUser(store *UserStore, name, email, password string) (*User, error) { // Validate if err := validateEmail(email); err != nil { return nil, err } // Check if exists if _, exists := store.FindByEmail(email); exists { return nil, fmt.Errorf("email already registered") } // Create user user := &User{ Name: name, Email: email, PasswordHash: hashPassword(password), } // Save if err := store.Save(user); err != nil { return nil, err } return user, nil } // AuthenticateUser authenticates a user func AuthenticateUser(store *UserStore, email, password string) (*User, error) { user, exists := store.FindByEmail(email) if !exists { return nil, fmt.Errorf("user not found") } if user.PasswordHash != hashPassword(password) { return nil, fmt.Errorf("invalid password") } return &user, nil } func main() { fmt.Println("╔══════════════════════════════════════════════════════════╗") fmt.Println("║ YAGNI: Simple User Management ║") fmt.Println("╚══════════════════════════════════════════════════════════╝") store := NewUserStore() // Register users fmt.Println("\n👤 Registering users:") fmt.Println("─"*50) user1, err := RegisterUser(store, "Alice", "alice@example.com", "password123") if err != nil { fmt.Printf("❌ Failed: %v\n", err) } else { fmt.Printf("✅ Registered: %s (ID: %d)\n", user1.Name, user1.ID) } user2, err := RegisterUser(store, "Bob", "bob@example.com", "secret456") if err != nil { fmt.Printf("❌ Failed: %v\n", err) } else { fmt.Printf("✅ Registered: %s (ID: %d)\n", user2.Name, user2.ID) } // Try duplicate email _, err = RegisterUser(store, "Alice2", "alice@example.com", "pass") if err != nil { fmt.Printf("✅ Correctly rejected duplicate: %v\n", err) } // Authenticate fmt.Println("\n🔐 Authenticating:") fmt.Println("─"*50) authed, err := AuthenticateUser(store, "alice@example.com", "password123") if err != nil { fmt.Printf("❌ Auth failed: %v\n", err) } else { fmt.Printf("✅ Authenticated: %s\n", authed.Name) } // Wrong password _, err = AuthenticateUser(store, "alice@example.com", "wrongpassword") if err != nil { fmt.Printf("✅ Correctly rejected bad password: %v\n", err) } fmt.Println("\n" + "═"*60) fmt.Println("Lines of code: ~100 vs ~300+ with unnecessary interfaces") fmt.Println("Time to implement: 1 hour vs 4 hours") fmt.Println("Flexibility: Add interfaces WHEN you need them") fmt.Println("═"*60) }
Expected Output:
╔══════════════════════════════════════════════════════════╗ ║ YAGNI: Simple User Management ║ ╚══════════════════════════════════════════════════════════╝ 👤 Registering users: ────────────────────────────────────────────────── ✅ Registered: Alice (ID: 1) ✅ Registered: Bob (ID: 2) ✅ Correctly rejected duplicate: email already registered 🔐 Authenticating: ────────────────────────────────────────────────── ✅ Authenticated: Alice ✅ Correctly rejected bad password: invalid password ════════════════════════════════════════════════════════════ Lines of code: ~100 vs ~300+ with unnecessary interfaces Time to implement: 1 hour vs 4 hours Flexibility: Add interfaces WHEN you need them ════════════════════════════════════════════════════════════
Example 3: The Configurable Everything
go// Filename: bad_over_configurable.go // YAGNI VIOLATION: Making everything configurable "just in case" package main import ( "encoding/json" "time" ) // ServerConfig with EVERY possible option type ServerConfig struct { // Basic settings Host string `json:"host"` Port int `json:"port"` Timeout int `json:"timeout"` // SSL settings (we don't use SSL yet) SSLEnabled bool `json:"ssl_enabled"` SSLCertPath string `json:"ssl_cert_path"` SSLKeyPath string `json:"ssl_key_path"` SSLMinVersion string `json:"ssl_min_version"` // Rate limiting (we don't rate limit yet) RateLimitEnabled bool `json:"rate_limit_enabled"` RateLimitRequests int `json:"rate_limit_requests"` RateLimitWindow int `json:"rate_limit_window"` RateLimitBurstSize int `json:"rate_limit_burst_size"` // Circuit breaker (we don't use this yet) CircuitBreakerEnabled bool `json:"circuit_breaker_enabled"` CircuitBreakerThreshold int `json:"circuit_breaker_threshold"` CircuitBreakerTimeout int `json:"circuit_breaker_timeout"` // Caching (we don't cache yet) CacheEnabled bool `json:"cache_enabled"` CacheType string `json:"cache_type"` // memory, redis, memcached CacheHost string `json:"cache_host"` CachePort int `json:"cache_port"` CachePassword string `json:"cache_password"` CacheTTL int `json:"cache_ttl"` CacheMaxItems int `json:"cache_max_items"` // Logging (we use console logging) LogLevel string `json:"log_level"` LogFormat string `json:"log_format"` // json, text LogOutput string `json:"log_output"` // stdout, file, syslog LogFilePath string `json:"log_file_path"` LogMaxSize int `json:"log_max_size"` LogMaxBackups int `json:"log_max_backups"` LogMaxAge int `json:"log_max_age"` LogCompress bool `json:"log_compress"` // Metrics (we don't collect metrics yet) MetricsEnabled bool `json:"metrics_enabled"` MetricsType string `json:"metrics_type"` // prometheus, statsd MetricsHost string `json:"metrics_host"` MetricsPort int `json:"metrics_port"` MetricsPrefix string `json:"metrics_prefix"` // Tracing (we don't trace yet) TracingEnabled bool `json:"tracing_enabled"` TracingType string `json:"tracing_type"` // jaeger, zipkin TracingHost string `json:"tracing_host"` TracingSampleRate float64 `json:"tracing_sample_rate"` // Health checks (overly configurable) HealthCheckEnabled bool `json:"health_check_enabled"` HealthCheckPath string `json:"health_check_path"` HealthCheckInterval int `json:"health_check_interval"` // ... 50 more options nobody asked for } // 90% of these options will NEVER be used! // Configuration file becomes a nightmare to maintain // Every option is another thing to document, test, and support
The YAGNI version:
go// Filename: good_simple_config.go // YAGNI COMPLIANT: Only configure what you actually use package main import ( "encoding/json" "fmt" "os" ) // ============================================================================= // SIMPLE SOLUTION: Configure only what you need TODAY // ============================================================================= // Config holds the configuration we actually use type Config struct { Port int `json:"port"` Debug bool `json:"debug"` DBHost string `json:"db_host"` DBName string `json:"db_name"` } // DefaultConfig returns sensible defaults func DefaultConfig() Config { return Config{ Port: 8080, Debug: false, DBHost: "localhost:5432", DBName: "myapp", } } // LoadConfig loads configuration // Why: Simple, handles the common cases // Why: Easy to extend WHEN we need more options func LoadConfig(filename string) (Config, error) { config := DefaultConfig() // Environment variables override file (12-factor app style) if port := os.Getenv("PORT"); port != "" { fmt.Sscanf(port, "%d", &config.Port) } if dbHost := os.Getenv("DB_HOST"); dbHost != "" { config.DBHost = dbHost } if os.Getenv("DEBUG") == "true" { config.Debug = true } // Load from file if exists file, err := os.Open(filename) if err == nil { defer file.Close() json.NewDecoder(file).Decode(&config) } return config, nil } func main() { config, _ := LoadConfig("config.json") fmt.Println("Configuration loaded:") fmt.Printf(" Port: %d\n", config.Port) fmt.Printf(" Debug: %v\n", config.Debug) fmt.Printf(" Database: %s/%s\n", config.DBHost, config.DBName) // That's it! Simple, clear, and sufficient. // When we need SSL, we add SSL config. // When we need caching, we add cache config. // NOT BEFORE! }
The YAGNI Decision Framework
Before building any feature, ask yourself:

Design principles diagram 3
Questions to Ask Before Every Feature
| Question | If "No"... |
|---|---|
| Is a real user asking for this? | Don't build it |
| Do we need it this sprint? | Add to backlog |
| Is this the simplest solution? | Simplify first |
| Can we ship something smaller? | Ship the MVP |
| Will we use this in the next month? | Delay building it |
When "Future-Proofing" Makes Sense
YAGNI doesn't mean never thinking ahead. Some things ARE worth considering:
Worth Considering Now
| Concern | Why |
|---|---|
| Security | Hard to add later, costly to get wrong |
| Data model | Migrations are expensive |
| API contracts | Breaking changes hurt users |
| Core architecture | Foundation is hard to change |
Not Worth Building Now
| Concern | Why Wait |
|---|---|
| Multiple database support | You'll probably never switch |
| Plugin systems | Build when you have multiple plugins |
| Configurable everything | Most configs never change |
| Scale for millions | You don't have millions yet |
go// Filename: yagni_decision_example.go // Demonstrating what to think about vs what to build package main import "fmt" // ============================================================================= // THINK ABOUT NOW (Design Decisions) // ============================================================================= // User has a good data model (think about this!) type User struct { ID int Email string // Unique, indexed Name string CreatedAt int64 // Unix timestamp - easy to work with // We THOUGHT about future fields but didn't add them: // - PreferredLanguage (add when needed) // - AvatarURL (add when needed) // - PhoneNumber (add when needed) } // API uses versioning (think about this!) const APIVersion = "v1" // Endpoint: /api/v1/users // Why: If we need v2, we can add it without breaking v1 // ============================================================================= // DON'T BUILD NOW (Features) // ============================================================================= // DON'T: Build multi-tenancy until you have multiple tenants // DON'T: Build caching until you have performance problems // DON'T: Build rate limiting until you have abuse // DON'T: Build webhooks until someone asks // DON'T: Build import/export until someone needs it // ============================================================================= // BUILD ONLY WHAT'S NEEDED // ============================================================================= // CreateUser creates a user - simple and direct func CreateUser(email, name string) (*User, error) { user := &User{ ID: 1, // Would come from database Email: email, Name: name, } return user, nil } func main() { fmt.Println("YAGNI Example") fmt.Println("=============") fmt.Println() fmt.Println("We THOUGHT about:") fmt.Println(" ✓ Data model (User struct is clean)") fmt.Println(" ✓ API versioning (easy to add v2 later)") fmt.Println(" ✓ Timestamps (Unix for simplicity)") fmt.Println() fmt.Println("We DIDN'T build:") fmt.Println(" ✗ Multi-tenancy (no tenants yet)") fmt.Println(" ✗ Caching layer (no performance issues)") fmt.Println(" ✗ Plugin system (no plugins)") fmt.Println(" ✗ Configurable everything (defaults work)") }
YAGNI and Technical Debt
A common argument against YAGNI: "Won't this create technical debt?"
The answer is nuanced:
Speculative Code IS Technical Debt
Code you wrote for imaginary requirements:
- Costs time to write
- Costs time to maintain
- Costs time to understand
- Costs time to test
- May never be used
That's negative ROI - worse than technical debt.
Missing Code is NOT Technical Debt
Not building a caching layer isn't debt. You don't owe anyone a caching layer. When you need it, you build it. Until then, you have a simpler system.

Design principles diagram 4
YAGNI Checklist
Before writing any code, verify:
| Check | Question |
|---|---|
| ✅ | Is there a real user story for this? |
| ✅ | Is this in the current sprint? |
| ✅ | Have I written the simplest possible solution? |
| ✅ | Am I avoiding "what if" features? |
| ✅ | Can I ship this in days, not weeks? |
| ✅ | Am I solving today's problem, not tomorrow's? |
Summary: The YAGNI Manifesto
Do Build
- Features users are asking for NOW
- The simplest solution that works
- Clean, maintainable code
- Good foundations (data model, API design)
Don't Build
- Features for imaginary users
- Features for "someday"
- Configurable everything
- Abstractions without multiple implementations
- Scale for users you don't have
Key Takeaways
- Real users > Imaginary users - Build for people who exist
- Now > Later - Focus on current requirements
- Simple > Flexible - Simplicity enables change
- Ship > Perfect - Get feedback, iterate
- Delete > Keep - Remove speculative code
Your Next Steps
- Audit: Find features built for "someday" that never came
- Delete: Remove code that's never used
- Question: For every new feature, ask "who is asking for this?"
- Read Next: OOP Principles
"The best code is no code at all." - Jeff Atwood
Remember: Every line of code is a liability. Write only what you need, when you need it.