All Posts

Understanding KISS, YAGNI, and DRY: Key Software Development Principles

Good code isn't just about syntax; it's about philosophy. We explain KISS (Keep It Simple), YAGNI...

Abstract AlgorithmsAbstract Algorithms
Β·Β·15 min read

AI-assisted content.

TLDR

TLDR: KISS (Keep It Simple), YAGNI (You Aren't Gonna Need It), and DRY (Don't Repeat Yourself) are the three most universally applicable software engineering mantras. They share a common enemy: unnecessary complexity.


πŸ“– The Complexity Tax

Every line of code you write is a liability. Someone has to read it, test it, debug it, migrate it, document it. Code you don't write costs zero.

KISS, YAGNI, and DRY are three angles of attack on the same problem: code that does more than it needs to, more times than necessary, in harder ways than required.


πŸ” KISS β€” The Clever Trap

KISS: Keep It Simple, Stupid.

Clever code is not a virtue. Clever code means future-you (or a teammate at 2am during an incident) has to spend 15 minutes understanding what you meant.

Violation β€” the unnecessarily clever solution:

# "Clever" one-liner: hard to read, hard to debug
result = sum(x**2 for x in nums if x > 0) or default

KISS-compliant version:

# Clear and debuggable
positives = [x for x in nums if x > 0]
squared   = [x**2 for x in positives]
result    = sum(squared) if positives else default

The second version is slightly longer but O(1) to understand. The one-liner has a subtle or default operator that behaves differently from if not squared for edge cases.

KISS tests:

  • Can a new team member understand this in 30 seconds?
  • If this line throws an exception, can you find the bug without a debugger?
  • Could you write this the same way in 6 months?

πŸ“Š Code Review Decision Tree: Which Principle Applies?

flowchart TD
    A[Code Review] --> B{Logic Duplicated?}
    B -- Yes --> C[Violates DRY]
    C --> D[Extract to Function/Class]
    B -- No --> E{Feature Not Needed Yet?}
    E -- Yes --> F[Violates YAGNI]
    F --> G[Remove Premature Code]
    E -- No --> H{Is It Too Complex?}
    H -- Yes --> I[Violates KISS]
    I --> J[Simplify]
    H -- No --> K[Code is Clean]

The flowchart guides a code reviewer through the three principles in priority order. The first gate checks for logic duplication (DRY violation), the second for unneeded features (YAGNI violation), and the third for unnecessary complexity (KISS violation). Reading the diagram top-to-bottom reveals that the principles are applied in sequence β€” clean code that passes all three gates reaches the "Code is Clean" terminal state, confirming that KISS, YAGNI, and DRY are complementary filters rather than competing rules.


βš™οΈ YAGNI β€” The Future-Proofing Fallacy

YAGNI: You Aren't Gonna Need It (from Extreme Programming).

Building for hypothetical future requirements is a tax on the present that usually never gets repaid.

Classic violation:

class PaymentProcessor:
    def __init__(self, provider: str = "stripe"):
        # "We might need PayPal someday" β€” not asked for
        if provider == "stripe":
            self.client = StripeClient()
        elif provider == "paypal":
            self.client = PayPalClient()
        elif provider == "adyen":
            self.client = AdyenClient()  # Never implemented
        else:
            raise ValueError("Unknown provider")

For a startup on Stripe only, this code is three times more complex than necessary, supports two untested paths, and will need to change when real requirements arrive anyway.

YAGNI-compliant version:

class PaymentProcessor:
    def __init__(self):
        self.client = StripeClient()  # The only provider we have

When PayPal is actually needed, refactor then β€” with real requirements, real tests, and real knowledge of what the interface needs to support.

Exceptions to YAGNI:

  • Security: baking in auth hooks now costs little and saves a painful retrofit later.
  • Public APIs: breaking changes to external client interfaces are very costly β€” design extension points deliberately.

🧠 Deep Dive: DRY β€” The Copy-Paste Debt

DRY: Don't Repeat Yourself.

Every piece of knowledge should have a single, authoritative representation. When you copy-paste logic, you create a maintenance bomb: a future change must be made in every copy, and they will diverge.

Violation:

# File 1: user registration
if len(password) < 8 or not any(c.isdigit() for c in password):
    raise ValidationError("Password too weak")

# File 2: password reset
if len(password) < 8 or not any(c.isdigit() for c in password):
    raise ValidationError("Password too weak")

When security rules change (add special character requirement), both copies must be updated β€” but only one will be.

DRY fix:

def validate_password(password: str) -> None:
    if len(password) < 8:
        raise ValidationError("Password must be at least 8 characters")
    if not any(c.isdigit() for c in password):
        raise ValidationError("Password must contain a digit")
    # Add future rules here β€” one place

# Both registration and reset call the same function
validate_password(new_password)

πŸ“Š DRY Refactoring: Centralising the Password Validation Rule

flowchart LR
    subgraph Before_DRY[Before DRY]
        A1[validate in registration]
        A2[validate in password reset]
    end
    subgraph After_DRY[After DRY]
        B1[validate_password]
        C1[registration] --> B1
        C2[password reset] --> B1
    end

The diagram contrasts the before and after states of a DRY refactoring. In the "Before DRY" cluster, two separate call sites each maintain their own copy of the password validation logic, creating two independent points of failure whenever the rule changes. In the "After DRY" cluster, both registration and password reset call the single canonical validate_password function β€” any future rule change propagates to both consumers automatically. The key takeaway: DRY is not about removing repeated text but about eliminating the divergence risk that comes from duplicated logic.


βš–οΈ Trade-offs & Failure Modes: When DRY Becomes WET

DRY is about logic, not text. Two functions that look similar but have different reasons to change should stay separate.

# These look duplicated but should NOT be merged
def validate_user_registration_email(email: str) -> None:
    # strict: must be a real domain, must not be disposable, must not exist in DB
    ...

def validate_password_reset_email(email: str) -> None:
    # lenient: just check it's valid format; we don't care if it's in DB
    ...

Merging these with a mode parameter creates an SRP violation β€” one function with two reasons to change.

Rule: DRY applies to logic that changes for the same reasons. If two pieces of code look similar but diverge when requirements change, they aren't the same knowledge.


πŸ“Š Principle Decision Flow

When you are about to write code, run it through this decision flowchart to check which principle applies.

flowchart TD
    A[About to write code] --> B{Is it solving a real current requirement?}
    B -- NO --> C[YAGNI: Don't write it]
    B -- YES --> D{Does similar logic already exist?}
    D -- YES --> E{Does it change for same reason?}
    E -- YES --> F[DRY: Extract and reuse]
    E -- NO --> G[Keep separate  not the same knowledge]
    D -- NO --> H{Is there a simpler way to write this?}
    H -- YES --> I[KISS: Use the simpler approach]
    H -- NO --> J[Write it  you're good]

Reading the flowchart: YAGNI is the first gate β€” don't build what you don't need. DRY is the second β€” if similar logic exists, check if it's truly the same knowledge before extracting. KISS is the third β€” always prefer the boring, obvious solution.


🌍 Real-World Application: KISS, YAGNI, and DRY at Scale

These principles scale from one-liner decisions to architectural choices.

PrincipleCode levelService levelArchitecture level
KISSAvoid ternary chainsSingle-purpose microservicePrefer synchronous HTTP over complex event mesh
YAGNIDon't add PayPal until neededDon't build auth service until auth is neededDon't deploy multi-region until you have users there
DRYExtract shared validationShare auth library across servicesSingle config management service

KISS in production incidents: Post-mortems frequently reveal that "clever" optimizations caused outages. A readable, boring approach to rate limiting (a Redis counter with TTL) beats a custom leaky-bucket implementation most engineers won't understand under 2am incident pressure.

YAGNI in startups: The most common startup engineering failure is over-engineering. Teams build multi-tenant SaaS infrastructure before their first paying customer, event-driven microservices before they need scale, and ML pipelines before they have data. YAGNI saves runway.

DRY in shared libraries: Centralizing business rules (pricing logic, validation rules, tax calculation) in shared libraries means a rule change propagates everywhere instantly. Without DRY, the same pricing bug must be patched in 8 services.

Anti-PatternPrinciple ViolatedFix
if feature == "x" elif feature == "y"... chainKISS + OCPStrategy pattern or plugin interface
"We might need this someday" abstractionYAGNIDelete it; add when required
Same regex copy-pasted in 6 filesDRYCentralized validator module
One-liner that requires 10 min to understandKISSExpand to 4 readable lines
Merging two functions with a mode parameterDRY misappliedKeep separate if they change for different reasons

πŸ§ͺ Worked Example: Order Price Calculation

This example walks through an OrderCalculator that simultaneously violates all three principles in a realistic e-commerce domain. It was chosen because it compresses KISS (unreadable nested ternaries), YAGNI (an unused cryptocurrency payment path), and DRY (duplicated currency-conversion logic) into a single relatable class β€” making all three problems visible side-by-side. As you read the "before" block, identify each violation by name before looking at the refactored version, then trace how each helper function addresses exactly one principle.

Starting point β€” everything wrong:

# πŸ›‘ Violates KISS (overcomplicated), DRY (logic repeated), and YAGNI (crypto never needed)
class OrderCalculator:
    def calculate_total(self, items, currency="USD", crypto_enabled=False,
                        apply_b2b_discount=False, loyalty_tier=None):
        total = sum(
            (i["price"] * i["qty"]) * (0.9 if apply_b2b_discount else 1) *
            (0.95 if loyalty_tier == "gold" else 0.97 if loyalty_tier == "silver" else 1)
            for i in items
        ) * (0.9 if currency == "EUR" else 1.1 if currency == "BTC" else 1)
        return total

    def calculate_shipping(self, items, currency="USD"):
        # Copy-pasted currency logic from above
        base = 5.0 if len(items) <= 3 else 10.0
        return base * (0.9 if currency == "EUR" else 1.1 if currency == "BTC" else 1)

Problems:

  • KISS: one-liner with chained ternaries β€” impossible to debug
  • DRY: currency conversion logic duplicated in both methods
  • YAGNI: crypto_enabled parameter never used; BTC handling that was "just in case"

Fixed β€” applying all three principles:

# βœ… KISS: readable and debuggable
# βœ… DRY: currency conversion is centralized
# βœ… YAGNI: no crypto, no unused parameters

def _apply_currency_rate(amount: float, currency: str) -> float:
    rates = {"USD": 1.0, "EUR": 0.9}         # Only currencies we support NOW
    return amount * rates.get(currency, 1.0)  # Single place to update rates

def _apply_discount(amount: float, apply_b2b: bool, loyalty_tier: str) -> float:
    if apply_b2b:
        amount *= 0.90
    if loyalty_tier == "gold":
        amount *= 0.95
    elif loyalty_tier == "silver":
        amount *= 0.97
    return amount

def calculate_total(items: list, currency: str = "USD",
                    apply_b2b: bool = False, loyalty_tier: str = None) -> float:
    subtotal = sum(item["price"] * item["qty"] for item in items)
    discounted = _apply_discount(subtotal, apply_b2b, loyalty_tier)
    return _apply_currency_rate(discounted, currency)

def calculate_shipping(items: list, currency: str = "USD") -> float:
    base_rate = 5.0 if len(items) <= 3 else 10.0
    return _apply_currency_rate(base_rate, currency)  # DRY: reuses same function

Toy dataset test:

items = [
    {"price": 25.00, "qty": 2},   # $50
    {"price": 10.00, "qty": 3},   # $30
]
# Subtotal: $80

print(calculate_total(items))                          # $80.00 USD
print(calculate_total(items, currency="EUR"))          # $72.00 EUR (Γ—0.9)
print(calculate_total(items, apply_b2b=True))          # $72.00 USD (Γ—0.9)
print(calculate_total(items, loyalty_tier="gold"))     # $76.00 USD (Γ—0.95)
print(calculate_shipping(items))                       # $10.00 (4 items > 3)
print(calculate_shipping(items, currency="EUR"))       # $9.00 EUR

Decision Guide

SituationRecommended Action
Code is hard to read or explain in 30 secondsKISS: rewrite in the simplest, most readable form
Building a feature "we might need someday"YAGNI: build only what is required now
Same logic copy-pasted in 3+ placesDRY: extract to a single canonical function
Two similar functions that change for different reasonsKeep separate β€” they are not the same knowledge
Clever optimization in a hot pathKISS unless profiling proves the optimization is needed

πŸ› οΈ Lombok and MapStruct: Eliminating Boilerplate the DRY Way in Java

Lombok is a Java annotation processor that generates getters, setters, constructors, equals/hashCode, and builders at compile time β€” removing the copy-pasted accessor boilerplate that violates DRY in every POJO. MapStruct generates type-safe mapping code between DTOs and domain objects at compile time, eliminating hand-written field-by-field mapping that is both tedious (KISS violation) and error-prone (DRY violation β€” copied across multiple service classes).

How they solve the problem in this post: The before/after below shows a typical Java service class. The "before" is verbose and violates DRY (getters/setters/builder scattered across files). The "after" uses Lombok to declare intent once (@Value, @Builder) and MapStruct to declare the mapping once instead of repeating target.setField(source.getField()) in every service method.

// ─── BEFORE: Verbose POJO violates DRY (getters copy-pasted, builder manual) ──
public class UserDto {
    private String firstName;
    private String lastName;
    private String email;

    public String getFirstName()        { return firstName; }
    public void setFirstName(String v)  { this.firstName = v; }
    // ... 4 more getters + setters β†’ pure boilerplate repeated in every DTO class
}

// ─── AFTER: Lombok β€” declare intent once, compiler generates the rest ──────────
// Dependencies: org.projectlombok:lombok:1.18.32  (annotationProcessor + provided)
import lombok.Value;
import lombok.Builder;

@Value   // immutable: final fields + all-args constructor + getters + equals/hashCode/toString
@Builder // fluent builder: UserDto.builder().firstName("Ada").email("a@b.com").build()
public class UserDto {
    String firstName;
    String lastName;
    String email;
}

// Domain entity (mutable, separate lifecycle)
@lombok.Data                      // mutable: getters + setters + equals + toString
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
public class User {
    private String id;
    private String firstName;
    private String lastName;
    private String email;
}

// ─── BEFORE: Hand-written mapping in every service violates DRY ───────────────
public UserDto toDto(User user) {
    UserDto dto = new UserDto(user.getFirstName(), user.getLastName(), user.getEmail());
    // Same pattern repeated in RegistrationService, AdminService, ProfileService…
    return dto;
}

// ─── AFTER: MapStruct generates type-safe mapping β€” one interface, zero loops ──
// Dependency: org.mapstruct:mapstruct:1.5.5 + mapstruct-processor (annotationProcessor)
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;

@Mapper
public interface UserMapper {
    UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);

    UserDto toDto(User user);       // MapStruct generates the body at compile time
    User   toEntity(UserDto dto);   // bidirectional β€” DRY: one place to update field names
}

// ─── Usage in a Spring service ────────────────────────────────────────────────
@Service
public class UserService {

    public UserDto getUser(String id) {
        User user = new User(id, "Ada", "Lovelace", "ada@example.com");
        return UserMapper.INSTANCE.toDto(user); // one line instead of N field copies
    }
}

// ─── Smoke test ───────────────────────────────────────────────────────────────
// UserDto dto = new UserService().getUser("42");
// System.out.println(dto);
// β†’ UserDto(firstName=Ada, lastName=Lovelace, email=ada@example.com)

Lombok eliminates ~70% of the boilerplate in a typical Java service layer β€” no more copy-pasted getter blocks (DRY), no more verbose builder patterns (KISS). MapStruct's generated mapping code is faster than reflection-based mappers (ModelMapper, Dozer) and fails the build if a field is renamed without updating the mapper β€” catching DRY violations at compile time rather than at runtime.

For a full deep-dive on Lombok and MapStruct patterns in Spring Boot services, a dedicated follow-up post is planned.


πŸ“š Lessons Learned From These Three Principles

  • KISS saves you at 2am. The code you write during normal business hours must be debuggable during an incident at 2am by someone who didn't write it. Boring is better than clever.
  • YAGNI is hardest to follow in high-excitement phases. The beginning of a project is when engineers are most tempted to build "enterprise-grade" infrastructure. This is when YAGNI has the most leverage β€” ship the simplest version that works.
  • DRY is about knowledge, not text. Two functions that look identical but diverge under different business rules are not the same knowledge. Don't merge them. The test: if one changes independently of the other, they should stay separate.
  • All three principles share a common enemy: future hypotheticals. YAGNI says don't build for them. KISS says don't optimize for them. DRY says don't abstract code that merely looks similar today but will diverge for different reasons.
  • The sweet spot is boring code that does exactly what is needed. If a reviewer can understand a function in 30 seconds, it passes KISS. If it was written only because there was a real requirement, it passes YAGNI. If there's one canonical place to find each piece of logic, it passes DRY.
  • SRP and DRY interact. Extracting a shared function (DRY) is correct when the extracted function has one responsibility. But if merging two similar-looking functions creates a multi-mode function that violates SRP, keep them separate.

πŸ“Œ TLDR: Summary & Key Takeaways

  • KISS: Prefer the boring, obvious solution. Clever code is a future liability.
  • YAGNI: Don't build for hypothetical needs. Build when you have real requirements and real tests.
  • DRY: Centralize logic that changes for the same reasons. Every piece of knowledge should have one home.
  • Over-DRY warning: Don't merge code that merely looks similar β€” if it changes for different reasons, duplication is the right call.

πŸ“Š KISS, YAGNI, and DRY: Scope and Focus at a Glance

flowchart TD
    A[Three Principles] --> B[DRY]
    A --> C[YAGNI]
    A --> D[KISS]
    B --> E[Avoid Duplication]
    B --> F[Single Source of Truth]
    C --> G[Build What Is Needed]
    C --> H[No Speculative Code]
    D --> I[Simple Solutions]
    D --> J[Easy to Understand]

The mind-map shows how all three principles fan out from a single root concern β€” avoiding unnecessary complexity. Each principle has two concrete sub-goals: DRY targets Avoid Duplication and Single Source of Truth; YAGNI targets Build What Is Needed and No Speculative Code; KISS targets Simple Solutions and Easy to Understand. The takeaway is that the three principles are not rivals but complementary lenses β€” YAGNI prevents you from building it, KISS keeps what you do build readable, and DRY ensures each piece of knowledge lives in exactly one place.



Share
Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms