Chapter 01 · Article 02 of 55

Software Design Principles

Software Design Principles are fundamental guidelines that help developers write code that is maintainable, flexible, and robust. These principles exist independently of any spe…

Article outline16 sections on this page

Overview

Software Design Principles are fundamental guidelines that help developers write code that is maintainable, flexible, and robust. These principles exist independently of any specific design pattern - they are the "why" behind good design decisions. Mastering these principles allows you to evaluate trade-offs and make informed choices even in unfamiliar problem domains.


Core Principles at a Glance

PrincipleOne-LinerKey Benefit
DRYDon't Repeat YourselfReduces maintenance burden
KISSKeep It Simple, StupidReduces cognitive load
YAGNIYou Aren't Gonna Need ItPrevents over-engineering
SoCSeparation of ConcernsEnables independent changes
LoDLaw of DemeterReduces coupling
CoIComposition over InheritanceIncreases flexibility
PoLAPrinciple of Least AstonishmentImproves usability
Fail FastDetect errors earlyEasier debugging

DRY - Don't Repeat Yourself

Theory

Every piece of knowledge must have a single, unambiguous, authoritative representation within a system. Duplication leads to inconsistency - when you change one copy but forget the other.

Violation Example

CLASS OrderService:
    METHOD calculateTotal(order):
        total = 0
        FOR item IN order.items:
            total = total + (item.price * item.quantity)
        tax = total * 0.18
        RETURN total + tax

CLASS InvoiceService:
    METHOD generateInvoice(order):
        total = 0
        FOR item IN order.items:
            total = total + (item.price * item.quantity)
        tax = total * 0.18  // Duplicated logic!
        invoice.amount = total + tax
        RETURN invoice

Correct Example

CLASS PricingCalculator:
    METHOD calculateTotal(order):
        subtotal = 0
        FOR item IN order.items:
            subtotal = subtotal + (item.price * item.quantity)
        RETURN subtotal

    METHOD calculateTax(amount, taxRate = 0.18):
        RETURN amount * taxRate

    METHOD calculateFinalAmount(order):
        subtotal = calculateTotal(order)
        tax = calculateTax(subtotal)
        RETURN subtotal + tax

When DRY Goes Wrong

DRY is about knowledge duplication, not code duplication. Two pieces of code that look identical but represent different business concepts should NOT be merged.

Example: Shipping address validation and billing address validation
may have identical rules TODAY but change for different reasons.
Merging them creates accidental coupling.

KISS - Keep It Simple, Stupid

Theory

The simplest solution that works is usually the best. Complexity should be added only when justified by requirements, not by anticipated future needs.

Violation Example

// Over-engineered string reversal
CLASS StringReverser:
    METHOD reverse(input):
        charArray = input.toCharArray()
        stack = new Stack()
        FOR char IN charArray:
            stack.push(char)
        result = ""
        WHILE NOT stack.isEmpty():
            result = result + stack.pop()
        RETURN result

Correct Example

CLASS StringReverser:
    METHOD reverse(input):
        RETURN input.reversed()

KISS in Design Decisions

ScenarioComplex ApproachKISS Approach
Config managementCustom XML parserUse standard config library
Object creationAbstract Factory with 5 layersSimple Factory or constructor
State managementFull State patternEnum + switch (if few states)
NotificationEvent bus with middlewareDirect method call (if 1 subscriber)

YAGNI - You Aren't Gonna Need It

Theory

Don't implement functionality until it's actually needed. Speculative features add complexity, increase testing burden, and often turn out to be wrong when the real requirement arrives.

Violation Example

// Building for "future" multi-currency support nobody asked for
CLASS Payment:
    amount: Decimal
    currency: Currency
    exchangeRate: Decimal
    baseCurrencyAmount: Decimal
    conversionTimestamp: DateTime
    conversionProvider: String

    METHOD convertTo(targetCurrency):
        // 50 lines of conversion logic
        // that nobody needs right now

Correct Example

// Build what's needed now
CLASS Payment:
    amount: Decimal

    METHOD process():
        // Process payment in default currency

YAGNI vs Planning Ahead

SituationApply YAGNIPlan Ahead
Feature nobody requested
Known upcoming requirement
"What if someday..."
Contractual obligation
Making code testable
Adding abstraction "just in case"

Separation of Concerns (SoC)

Theory

Each module/class/function should address a single concern. A concern is a distinct aspect of functionality. Mixing concerns makes code harder to understand, test, and modify.

Violation Example

CLASS UserController:
    METHOD createUser(request):
        // Validation (Concern 1)
        IF request.email IS empty:
            THROW ValidationError
        IF NOT isValidEmail(request.email):
            THROW ValidationError

        // Business Logic (Concern 2)
        user = new User(request.email, request.name)
        hashedPassword = hashPassword(request.password)
        user.setPassword(hashedPassword)

        // Persistence (Concern 3)
        db.connect()
        db.execute("INSERT INTO users VALUES (...)")
        db.close()

        // Notification (Concern 4)
        emailService.send(user.email, "Welcome!")

        RETURN user

Correct Example

CLASS UserValidator:
    METHOD validate(request):
        IF request.email IS empty OR NOT isValidEmail(request.email):
            THROW ValidationError

CLASS UserService:
    METHOD createUser(request):
        validator.validate(request)
        user = new User(request.email, request.name)
        user.setPassword(passwordHasher.hash(request.password))
        userRepository.save(user)
        eventBus.publish(new UserCreatedEvent(user))
        RETURN user

CLASS UserRepository:
    METHOD save(user):
        db.execute("INSERT INTO users VALUES (...)")

CLASS WelcomeEmailListener:
    METHOD onUserCreated(event):
        emailService.send(event.user.email, "Welcome!")

Layered Architecture (SoC Applied)

+-------------------+
|  Presentation     |  Controllers, Views, API endpoints
+-------------------+
         |
+-------------------+
|  Business Logic   |  Services, Domain objects, Rules
+-------------------+
         |
+-------------------+
|  Data Access      |  Repositories, DAOs, ORM
+-------------------+
         |
+-------------------+
|  Infrastructure   |  Database, File system, External APIs
+-------------------+

Law of Demeter (LoD) - Principle of Least Knowledge

Theory

A method should only talk to its immediate friends:

  1. The object itself
  2. Objects passed as parameters
  3. Objects it creates
  4. Its direct component objects

Don't talk to strangers. Avoid chaining calls like a.getB().getC().doSomething().

Violation Example

CLASS OrderProcessor:
    METHOD processOrder(order):
        // Reaching deep into object graph  -  fragile!
        city = order.getCustomer().getAddress().getCity()
        taxRate = taxService.getRateForCity(city)
        order.getCustomer().getWallet().deduct(order.getTotal() * (1 + taxRate))

Correct Example

CLASS OrderProcessor:
    METHOD processOrder(order):
        city = order.getShippingCity()        // Order knows how to get this
        taxRate = taxService.getRateForCity(city)
        order.chargeCustomer(order.getTotal() * (1 + taxRate))  // Order delegates

CLASS Order:
    METHOD getShippingCity():
        RETURN customer.getShippingCity()

    METHOD chargeCustomer(amount):
        customer.charge(amount)

CLASS Customer:
    METHOD getShippingCity():
        RETURN address.getCity()

    METHOD charge(amount):
        wallet.deduct(amount)

LoD Trade-offs

ProCon
Reduces couplingMay create many wrapper/delegate methods
Easier to refactor internalsCan feel verbose
Better encapsulationRequires more thought upfront

Composition over Inheritance

Theory

Favor composing objects (has-a) over inheriting behavior (is-a). Inheritance creates tight coupling between parent and child, makes hierarchies rigid, and can lead to the fragile base class problem.

Violation Example (Inheritance)

CLASS Animal:
    METHOD move(): print("walking")
    METHOD makeSound(): ABSTRACT

CLASS Bird EXTENDS Animal:
    METHOD makeSound(): print("chirp")
    // Problem: Not all birds fly, but Bird inherits walk from Animal
    // What about Penguin? Duck that swims?

CLASS FlyingBird EXTENDS Bird:
    METHOD move(): print("flying")

CLASS SwimmingBird EXTENDS Bird:
    METHOD move(): print("swimming")

// What about a duck that both flies AND swims? Multiple inheritance mess.

Correct Example (Composition)

INTERFACE MovementBehavior:
    METHOD move()

CLASS WalkBehavior IMPLEMENTS MovementBehavior:
    METHOD move(): print("walking")

CLASS FlyBehavior IMPLEMENTS MovementBehavior:
    METHOD move(): print("flying")

CLASS SwimBehavior IMPLEMENTS MovementBehavior:
    METHOD move(): print("swimming")

CLASS Bird:
    movements: List<MovementBehavior>
    sound: SoundBehavior

    CONSTRUCTOR(movements, sound):
        this.movements = movements
        this.sound = sound

    METHOD performMovements():
        FOR movement IN movements:
            movement.move()

// Duck can fly AND swim
duck = new Bird([new FlyBehavior(), new SwimBehavior()], new QuackSound())

When to Use Inheritance vs Composition

Use Inheritance WhenUse Composition When
True "is-a" relationship"Has-a" or "can-do" relationship
Shared behavior won't changeBehavior varies or is swappable
Shallow hierarchy (1-2 levels)Deep hierarchies emerging
Framework requires itFlexibility needed
Liskov Substitution holdsMultiple behaviors needed

Principle of Least Astonishment (PoLA)

Theory

Software should behave in a way that least surprises the user (or developer). Method names should accurately describe what they do. Side effects should be obvious or absent.

Violation Example

CLASS UserService:
    // Name says "get" but it also CREATES if not found  -  surprising!
    METHOD getUser(id):
        user = repository.findById(id)
        IF user IS null:
            user = new User(id, "default")
            repository.save(user)
            emailService.sendWelcome(user)  // Side effect!
        RETURN user

Correct Example

CLASS UserService:
    METHOD getUser(id):
        RETURN repository.findById(id)  // Returns null if not found

    METHOD getOrCreateUser(id):
        user = repository.findById(id)
        IF user IS null:
            user = createDefaultUser(id)
        RETURN user

    METHOD createDefaultUser(id):
        user = new User(id, "default")
        repository.save(user)
        eventBus.publish(new UserCreatedEvent(user))
        RETURN user

Fail Fast Principle

Theory

If something is going to fail, it should fail immediately and visibly rather than continuing in a corrupted state. Validate inputs at boundaries, check preconditions, and throw meaningful errors early.

Example

CLASS BankAccount:
    METHOD withdraw(amount):
        // Fail fast  -  validate immediately
        IF amount <= 0:
            THROW InvalidAmountError("Amount must be positive: " + amount)
        IF amount > this.balance:
            THROW InsufficientFundsError("Balance: " + balance + ", Requested: " + amount)

        this.balance = this.balance - amount
        RETURN new Transaction(amount, "WITHDRAWAL")

Principles Comparison Matrix

PrinciplePreventsCostsTension With
DRYInconsistency, maintenance burdenMay create premature abstractionsKISS (sometimes DRY adds complexity)
KISSOver-engineering, cognitive overloadMay limit extensibilityOpen-Closed Principle
YAGNIWasted effort, unused codeMay require refactoring laterPlanning ahead
SoCTangled code, ripple effectsMore files/classesKISS (more indirection)
LoDTight coupling, fragile codeMore delegate methodsConvenience
Composition > InheritanceRigid hierarchies, fragile base classMore objects to manageSimplicity for true "is-a"
PoLABugs from unexpected behaviorRequires more thought in namingClever optimizations
Fail FastSilent corruption, hard-to-debug errorsMore validation codeGraceful degradation

Constraints & Edge Cases

  • DRY taken too far → Premature abstraction that couples unrelated concepts
  • KISS taken too far → No abstraction at all, leading to rigid code
  • YAGNI taken too far → No extensibility points, expensive refactoring later
  • SoC taken too far → Too many tiny classes, hard to follow the flow
  • LoD taken too far → Explosion of wrapper methods, "middle man" smell

The art of design is knowing when to apply each principle and how much.


Interview Follow-ups

Q&A Style

Q: Can DRY and KISS conflict? Give an example.

A: Yes. Removing duplication (DRY) sometimes requires creating abstractions (base classes, utility functions, generics) that add complexity, violating KISS. Example: Two services have similar but not identical validation logic. DRYing them into a shared generic validator with configuration options may be more complex than keeping two simple, independent validators. The right choice depends on how likely the logic is to diverge further.

Q: How do you decide when YAGNI applies vs when you should plan ahead?

A: YAGNI applies to features and concrete implementations. Planning ahead applies to structure - using interfaces, keeping classes focused, and avoiding tight coupling. You don't build the multi-currency system, but you don't hardcode "$" everywhere either. The test: "Would adding this later require changing existing code?" If yes, add a minimal abstraction point now.

Hints for Self-Practice

Q: You're building a notification system. The PM says "just email for now, but we might add SMS and push later." How do you apply these principles?

  • Think about which principle says "don't build SMS now"
  • Think about which principle says "don't hardcode EmailService everywhere"
  • Consider what minimal abstraction enables future extension without building it now
  • Think about the Open-Closed Principle connection

Q: A colleague argues that having 50 small classes violates KISS. How do you respond?

  • Consider what "simple" means - simple to understand vs simple in structure
  • Think about whether each class is independently simple
  • Consider the alternative - fewer classes but each one complex
  • Think about discoverability and navigation

Counter Questions to Ask Interviewer

  • "Should I prioritize simplicity or extensibility for this design?"
  • "How likely are the requirements to change in this area?"
  • "Is this a prototype/MVP or production-grade system?"
  • "Are there performance constraints that might override clean design?"

References & Whitepapers

  • "The Pragmatic Programmer" by Hunt & Thomas - DRY principle origin (Chapter: "The Evils of Duplication")
  • "Clean Architecture" by Robert C. Martin - Separation of Concerns, dependency rules
  • "Design Patterns" (GoF, 1994) - "Favor object composition over class inheritance"
  • "Law of Demeter" - Karl Lieberherr, 1987, Northeastern University
  • "Agile Software Development" by Robert C. Martin - SOLID + supporting principles
  • Paper: "On the Criteria To Be Used in Decomposing Systems into Modules" - David Parnas, 1972 (foundational paper on SoC and information hiding)