Software Design Principles

Table of Contents

Software Design Principles

Software design principles solve fundamental issues in building reliable, scalable software systems. They tackle problems like code rigidity, duplication, and complexity that plague real-world development.

We will be covering the following topics:

  • DRY (Don't Repeat Yourself)
  • KISS (Keep It Simple, Stupid)
  • YAGNI (You Aren't Gonna Need It)

1. DRY (Don't Repeat Yourself)

DRY states that we should avoid duplicating code or logic. Every piece of knowledge must have a single, unambiguous, authoritative representation. That means, every code, data, config - should exist in one, and only one, authoritative place.

  • It reduces maintenance overhead (fix bugs once).
  • It improves code reusability and consistency.
  • It lowers risk of introducing inconsistencies.

Use Case: User Validation

Problem statement: Create a user validation logic for user creation and authentication.

Let's look at a case where we don't use DRY principle.

// UserService.java
public class UserService {
    public void createUser(String username, String email) {
        if (username == null || username.length() < 5) {
            throw new IllegalArgumentException("Invalid username");
        }
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
        // Create user...
    }
}

// AuthService.java
public class AuthService {
    public void login(String username, String email) {
        if (username == null || username.length() < 5) {
            throw new IllegalArgumentException("Invalid username");
        }
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
        // Authenticate...
    }
}

Key issues:

  • Validation logic duplicated. For both user creation and authentication, we are validating the username and email.
  • Changing rules (e.g., username length) requires updates in multiple places.

Solution:

// Validator.java (Single source of truth)
public class Validator {
    public static void validateUser(String username, String email) {
        if (username == null || username.length() < 5) {
            throw new IllegalArgumentException("Invalid username");
        }
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
    }
}

// UserService.java
public class UserService {
    public void createUser(String username, String email) {
        Validator.validateUser(username, email);
        // Create user...
    }
}

// AuthService.java
public class AuthService {
    public void login(String username, String email) {
        Validator.validateUser(username, email);
        // Authenticate...
    }
}

Outcome:

  • Validation logic centralized. Now, we don't need to add the validation logic in multiple places.
  • Changes only needed in Validator.java.

2. KISS (Keep It Simple, Stupid)

We should always solves problems using the simplest solution possible. Avoid overcomplicating design or implementation.

  • It makes simple code easier to read, debug, and maintain.
  • It reduces cognitive load for developers.
  • It minimizes the chance of errors.

Use Case: Discount Calculation

Problem: Calculate a discount for an order.

Before (Overcomplicated):

// DiscountService.java
public class DiscountService {
    public double calculateDiscount(Order order, User user) {
        // Complex nested logic with multiple conditions
        double discount = 0;
        if (user.isPremium()) {
            if (order.getTotal() > 1000) {
                discount = order.getTotal() * 0.2;
                if (user.hasLoyaltyCard()) {
                    discount += 50;
                }
            } else {
                discount = order.getTotal() * 0.1;
            }
        } else {
            if (order.getTotal() > 500) {
                discount = order.getTotal() * 0.05;
            }
        }
        return discount;
    }
}

Issues:

  • Hard to read and modify.
  • Logic intertwined with user/order details.

After (KISS Compliant):

// DiscountService.java
public class DiscountService {
    public double calculateDiscount(Order order, User user) {
        double discount = 0;
        
        // Apply premium discount
        if (user.isPremium()) {
            discount += order.getTotal() * 0.2;
        } else if (order.getTotal() > 500) {
            discount += order.getTotal() * 0.05;
        }
        
        // Apply loyalty bonus
        if (user.hasLoyaltyCard()) {
            discount += 50;
        }
        
        return discount;
    }
}

Outcome:

  • Linear flow, easy to understand.
  • Each rule is isolated and explicit.

3. YAGNI (You Aren't Gonna Need It)

Only implement functionality that is immediately required. Avoid speculative features.

  • Prevents wasted effort on unused code.
  • Reduces codebase complexity.
  • Accelerates delivery of core features.

Use Case: Payment Processing

Problem: Design a payment system with credit card and paypal as services.

Before: An overengineered solution where we implemented 5 payment methods when we only need 2.

// PaymentProcessor.java
public class PaymentProcessor {
    public void processPayment(Payment payment) {
        // Supports 5 payment methods (Credit Card, PayPal, Crypto, etc.)
        if (payment.getType() == PaymentType.CREDIT_CARD) {
            processCreditCard(payment);
        } else if (payment.getType() == PaymentType.PAYPAL) {
            processPayPal(payment);
        }
        // ... 3 more unused payment methods
    }
    
    private void processCreditCard(Payment payment) { /* ... */ }
    private void processPayPal(Payment payment) { /* ... */ }
    // Unused methods for Crypto, Bank Transfer, etc.
}

Issues:

  • Implemented 5 payment methods when only 2 are needed now.
  • Dead code increases maintenance burden.

After: A simple solution where we only implement what's needed now.

// PaymentProcessor.java
public class PaymentProcessor {
    public void processPayment(Payment payment) {
        // Only implement what's needed NOW
        if (payment.getType() == PaymentType.CREDIT_CARD) {
            processCreditCard(payment);
        } else if (payment.getType() == PaymentType.PAYPAL) {
            processPayPal(payment);
        } else {
            throw new UnsupportedOperationException("Unsupported payment type");
        }
    }
    
    private void processCreditCard(Payment payment) { /* ... */ }
    private void processPayPal(Payment payment) { /* ... */ }
}

Outcome:

  • No unused code.
  • Easy to extend later when new payment methods are required.

Some conclusive points:

Q. When to Apply these principles?

  • DRY: When you see identical code/logic in ≥2 places.
  • KISS: When a solution requires more than 10 seconds to understand.
  • YAGNI: When adding "just-in-case" features.

Q. What are anti-patterns to avoid?

  • DRY: Copy-pasting code.
  • KISS: Using 10 design patterns for a simple task.
  • YAGNI: Building a "framework" for a one-time script.
← Solid Principles