KISS Principle: Keep It Simple

The KISS principle—'Keep It Simple, Stupid'—originated not in software but in aerospace. Kelly Johnson, the legendary engineer behind Lockheed's Skunk Works, demanded that aircraft be designed so a...

Key Insights

  • Complexity is the default state of software—simplicity requires deliberate effort and constant vigilance against “what if” thinking that leads to premature abstraction.
  • Every layer of abstraction, every design pattern, and every architectural decision carries cognitive overhead that compounds over time and across team members.
  • The path to simplicity isn’t about writing less code; it’s about solving today’s actual problems with the most straightforward approach that leaves room for evolution.

What is KISS?

The KISS principle—“Keep It Simple, Stupid”—originated not in software but in aerospace. Kelly Johnson, the legendary engineer behind Lockheed’s Skunk Works, demanded that aircraft be designed so a average mechanic could repair them in combat conditions with basic tools. The constraint wasn’t about dumbing things down; it was about survival.

In software, we face a similar reality. Complexity kills projects. It kills developer productivity, team morale, and eventually the product itself. Yet complexity is seductive. It feels like progress. Adding another abstraction layer, implementing a sophisticated pattern, or adopting a cutting-edge architecture signals technical sophistication. But sophistication isn’t the goal—working software is.

The core insight of KISS is that simplicity must be a design goal, not an afterthought. You don’t stumble into simple solutions; you fight for them against the gravitational pull of complexity that affects every software project.

The Hidden Costs of Complexity

Complexity extracts payment in ways that don’t show up in sprint planning. The most significant cost is cognitive load. Every abstraction, every indirection, every clever trick requires mental bandwidth to understand. That bandwidth is finite.

Consider bug surface area. Complex systems have more places where things can go wrong, more interactions between components that can produce unexpected behavior, and more edge cases that testing might miss. A study by Microsoft Research found that code complexity metrics strongly correlate with defect density—the more complex the code, the more bugs it contains.

Then there’s the team impact. How long does it take a new developer to become productive in your codebase? If the answer is months rather than weeks, complexity is likely the culprit. Every unnecessary abstraction extends that timeline and reduces team velocity.

Here’s a concrete example of complexity masquerading as cleverness:

# The "clever" one-liner
users = {u.id: u for u in filter(lambda u: u.active and u.role in ['admin', 'editor'] and (u.last_login or datetime.min) > cutoff, all_users)}

# The readable equivalent
active_users = {}
allowed_roles = ['admin', 'editor']

for user in all_users:
    if not user.active:
        continue
    if user.role not in allowed_roles:
        continue
    if user.last_login and user.last_login <= cutoff:
        continue
    active_users[user.id] = user

The second version is longer but infinitely more debuggable. You can set breakpoints, add logging, and understand the logic at a glance. The one-liner requires mental unpacking every single time someone reads it.

Recognizing Over-Engineering

Over-engineering often stems from good intentions. We want our code to be flexible, extensible, and ready for future requirements. But this “what if we need to…” thinking leads us to build for scenarios that never materialize.

Signs your solution is too complex:

  • You’ve created abstractions that have only one implementation
  • You’re passing configuration objects with dozens of options, most set to defaults
  • New team members consistently struggle to trace how data flows through the system
  • You’ve implemented design patterns because they’re “best practices” rather than solving a specific problem

Here’s a classic example of over-engineering:

// Over-abstracted: Factory pattern for a single use case
public interface NotificationFactory {
    Notification create(NotificationConfig config);
}

public class EmailNotificationFactory implements NotificationFactory {
    private final TemplateEngine templateEngine;
    private final ConfigurationProvider configProvider;
    
    public EmailNotificationFactory(TemplateEngine engine, ConfigurationProvider provider) {
        this.templateEngine = engine;
        this.configProvider = provider;
    }
    
    @Override
    public Notification create(NotificationConfig config) {
        return new EmailNotification(
            templateEngine,
            configProvider.getEmailSettings(),
            config
        );
    }
}

// Usage requires understanding multiple abstractions
NotificationFactory factory = new EmailNotificationFactory(engine, provider);
Notification notification = factory.create(config);
notification.send();

// Simple alternative when you only have email notifications
public class EmailNotification {
    public EmailNotification(String recipient, String subject, String body) {
        this.recipient = recipient;
        this.subject = subject;
        this.body = body;
    }
    
    public void send() {
        // Send the email directly
        emailClient.send(recipient, subject, body);
    }
}

// Usage is obvious
new EmailNotification("user@example.com", "Welcome", "Hello!").send();

The factory pattern is valuable when you have multiple notification types and need runtime selection. But if you only send emails, you’ve added three classes and two interfaces to do what a constructor could accomplish.

KISS in Practice: Code-Level Simplicity

Practical simplicity comes down to a few key habits.

Favor explicit over implicit. Magic is the enemy of maintainability. When behavior is hidden in decorators, metaclasses, or framework conventions, debugging becomes archaeology.

Reduce nesting and cyclomatic complexity. Deep nesting forces readers to maintain mental stacks of conditions. Early returns flatten this structure:

// Deeply nested - hard to follow
func processOrder(order Order) error {
    if order.IsValid() {
        if order.HasInventory() {
            if order.PaymentVerified() {
                if order.ShippingAvailable() {
                    // Finally, the actual logic
                    return fulfillOrder(order)
                } else {
                    return errors.New("shipping unavailable")
                }
            } else {
                return errors.New("payment not verified")
            }
        } else {
            return errors.New("insufficient inventory")
        }
    } else {
        return errors.New("invalid order")
    }
}

// Guard clauses - linear and clear
func processOrder(order Order) error {
    if !order.IsValid() {
        return errors.New("invalid order")
    }
    if !order.HasInventory() {
        return errors.New("insufficient inventory")
    }
    if !order.PaymentVerified() {
        return errors.New("payment not verified")
    }
    if !order.ShippingAvailable() {
        return errors.New("shipping unavailable")
    }
    
    return fulfillOrder(order)
}

The guard clause version reads top to bottom. Each condition is independent. Adding a new check means adding two lines, not restructuring nested blocks.

Choose boring technology. That new database might have impressive benchmarks, but PostgreSQL has decades of battle-testing, extensive documentation, and developers who already know it. Boring technology reduces risk and cognitive overhead.

KISS in Architecture

Architectural simplicity matters even more than code-level simplicity because architectural decisions are expensive to reverse.

The monolith-first approach isn’t about being anti-microservices—it’s about earning complexity. A well-structured monolith can be decomposed later when you understand your domain boundaries. Premature microservices mean you’re guessing at those boundaries while also managing distributed systems complexity.

Consider this contrast:

# Simple: Direct database query for a basic feature
def get_user_orders(user_id: int) -> list[Order]:
    return db.query(
        "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC",
        [user_id]
    )

# Over-engineered: Event-driven architecture for the same feature
# Requires: message broker, event schema registry, consumer service,
# eventual consistency handling, dead letter queues...

class OrderQueryRequestedEvent:
    user_id: int
    correlation_id: str
    
def request_user_orders(user_id: int) -> str:
    event = OrderQueryRequestedEvent(user_id, generate_correlation_id())
    message_broker.publish("order.query.requested", event)
    return event.correlation_id  # Client must poll for results

# Plus a separate consumer service, result caching layer, 
# websocket notifications for completion...

Event-driven architecture has legitimate uses: decoupling services at scale, handling high-throughput scenarios, enabling complex workflows. But for fetching a user’s orders? You’ve traded a function call for a distributed system.

Balancing Simplicity with Other Concerns

Simplicity isn’t always the highest priority. Complexity is justified when:

  • Scale demands it. At millions of requests per second, you might genuinely need that caching layer, that message queue, that read replica.
  • Compliance requires it. Audit logging, encryption requirements, and access controls add complexity but aren’t optional.
  • Performance is critical. Sometimes the simple solution is too slow, and optimization necessarily adds complexity.

The key is that these should be responses to measured problems, not anticipated ones. “We might need to scale” is not the same as “our database is falling over.”

Simple also doesn’t mean simplistic. Under-engineering—skipping error handling, ignoring edge cases, hardcoding values that should be configurable—creates different problems. The goal is appropriate simplicity: solving the actual problem with the minimum necessary complexity.

The refactoring path provides a way forward: start with the simplest solution that works, then evolve as requirements clarify. It’s easier to add complexity to simple code than to remove complexity from over-engineered code.

Simplicity as a Practice

Before adding complexity, ask yourself:

  1. What specific problem does this solve that I’m facing today?
  2. What’s the simplest solution that addresses that problem?
  3. What will this cost in terms of cognitive overhead for the team?
  4. Can I defer this decision until I have more information?

The best code is code you don’t have to write. Every line is a liability—it must be understood, maintained, and debugged. The KISS principle isn’t about being lazy or unsophisticated. It’s about respecting the finite cognitive resources of yourself and your team, and recognizing that complexity is a cost that compounds over time.

Simple solutions aren’t always obvious. They often require more thought than complex ones. But that investment pays dividends every time someone reads, modifies, or debugs your code. Fight for simplicity. Your future self will thank you.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.