Back to SOLID Principles
Q4Question 4 of 5

Why "don't over-engineer" is a core LLD skill, and answering "how would you extend this?" with the Open/Closed Principle

Key Concepts
  • Over-engineering shows up as forced patterns, God classes, speculative generality, and premature abstraction.
  • Start simple and refactor: write the cohesive model first, apply a pattern when a real pressure appears.
  • A God class violates SRP by doing many unrelated things, becoming a merge-conflict magnet.
  • Answer "how would you extend this?" with OCP: add a new class implementing the existing interface, never modify tested code.
  • Balance: do not pre-build abstractions for every axis of change – place a seam only where change is genuinely likely.
  • Untouched code does not need re-testing and cannot regress, which is critical in banking systems.

Definition

Build the simplest correct design and refactor when needed, then extend by adding a new class rather than modifying existing code, which is the Open/Closed Principle.
  • Not over-engineering – building the simplest design that correctly satisfies current requirements, avoiding speculative abstractions and forced patterns.
  • God class – a class that does too many things and changes for too many reasons; it violates SRP and is a merge-conflict magnet.
  • Start simple, refactor – introduce a pattern only when a real need appears: a second implementation, a growing switch, a hard-to-test dependency.
  • Open/Closed Principle – new behaviour is added by writing a new class that plugs into an existing abstraction, not by editing existing, tested code.
  • Seam – a small interface placed where change is likely, so extension is easy without pre-building for every possible future.

Suppose National Bank of India asks Rajesh to design a system that today supports exactly one kind of fee. Over-engineering is building a configurable rule engine, a plugin framework, and a message bus for that single fee: a mansion when a room was asked for. The skilled move is to build the simple room cleanly and leave an obvious doorway for a future room. Then when Pankaj asks "what if we add a second fee type next year?", Rajesh answers: because I kept the fee behind a small interface, I just add one new class and never touch the existing fee code.

Understanding the Concepts

What is over-engineering in LLD?

Over-engineering is adding complexity the problem does not justify. It shows up in four forms:

  • Forced patterns – using Strategy, Factory, or Observer where a plain method would do.
  • God class – one class that accumulates many responsibilities and becomes impossible to change safely.
  • Speculative generality – building for imagined future requirements that may never arrive.
  • Premature abstraction – extracting interfaces and layers before there is any real variation.
JAVA
package com.bank.service;

import java.math.BigDecimal;

// OVER-ENGINEERED: interface + factory + strategy for ONE fixed fee
public interface FeeCalculator {
    BigDecimal calculate(BigDecimal amount);
}

public class FlatFeeCalculator implements FeeCalculator {
    public BigDecimal calculate(BigDecimal amount) {
        return new BigDecimal("50.00");
    }
}

public class FeeCalculatorFactory {
    public static FeeCalculator create(String type) {
        return new FlatFeeCalculator(); // only one implementation!
    }
}

// SIMPLE AND SUFFICIENT:
public class FeeService {
    private static final BigDecimal FLAT_FEE = new BigDecimal("50.00");

    public BigDecimal calculateFee(BigDecimal amount) {
        return FLAT_FEE;
    }
}

The interface, factory, and strategy add three files and indirection for a problem that is just one constant.

Why is avoiding over-engineering a core LLD skill?

In an LLD interview, piling on patterns signals immaturity, not expertise. Senior engineers know that every abstraction has a cost, and the simplest design that meets the requirements is usually the most maintainable. The judgment of when a pattern earns its place is exactly what distinguishes a strong candidate.

JAVA
package com.bank.service;

import java.math.BigDecimal;

// GOD CLASS: too many responsibilities
public class AccountManagerGod {
    public void openAccount(String name) { /* ... */ }
    public void closeAccount(String id) { /* ... */ }
    public BigDecimal calculateInterest(String id) { return BigDecimal.ZERO; }
    public void sendStatement(String id) { /* ... */ }
    public void processLoan(String id, BigDecimal amount) { /* ... */ }
    public void detectFraud(String id) { /* ... */ }
    // Changes for six different reasons!
}

// SPLIT: each class has one responsibility
public class AccountService {
    public void open(String name) { /* ... */ }
    public void close(String id) { /* ... */ }
}

public class InterestService {
    public BigDecimal calculate(String id) { return BigDecimal.ZERO; }
}

public class StatementService {
    public void send(String id) { /* ... */ }
}

How do you start simple and refactor?

The disciplined approach is to write the cohesive, straightforward model first, keep responsibilities clear, and introduce a pattern precisely when a real pressure appears.

JAVA
package com.bank.service;

import java.math.BigDecimal;

// Step 1: start simple – one fee, plain method
public class TransferService {
    private static final BigDecimal FEE = new BigDecimal("50.00");

    public void transfer(String from, String to, BigDecimal amount) {
        BigDecimal fee = FEE;
        BigDecimal total = amount.add(fee);
        System.out.println("Transfer " + amount + " + fee " + fee + " = " + total);
    }
}

// Step 2: when a REAL second fee type appears, refactor to strategy
public interface FeeStrategy {
    BigDecimal calculate(BigDecimal amount);
}

public class FlatFee implements FeeStrategy {
    public BigDecimal calculate(BigDecimal amount) {
        return new BigDecimal("50.00");
    }
}

public class PercentageFee implements FeeStrategy {
    public BigDecimal calculate(BigDecimal amount) {
        return amount.multiply(new BigDecimal("0.001"));
    }
}

public class TransferServiceV2 {
    private final FeeStrategy feeStrategy;

    public TransferServiceV2(FeeStrategy feeStrategy) {
        this.feeStrategy = feeStrategy;
    }

    public void transfer(String from, String to, BigDecimal amount) {
        BigDecimal fee = feeStrategy.calculate(amount);
        BigDecimal total = amount.add(fee);
        System.out.println("Transfer " + amount + " + fee " + fee + " = " + total);
    }
}

The pattern earns its place when the second real case appears.

How do you answer "how would you extend this?" with OCP?

The Open/Closed Principle says software should be open for extension but closed for modification. When the interviewer asks how you would add a new vehicle type, payment method, or fee rule, the ideal answer is: "I add a new class that implements the existing interface, and I register it. I do not modify the existing classes at all."

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.util.Map;

public class FeeModule {
    private final Map<String, FeeStrategy> strategies;

    public FeeModule(Map<String, FeeStrategy> strategies) {
        this.strategies = strategies;
    }

    public BigDecimal calculateFee(String type, BigDecimal amount) {
        FeeStrategy strategy = strategies.get(type);
        if (strategy == null) {
            throw new IllegalArgumentException("Unknown fee type: " + type);
        }
        return strategy.calculate(amount);
    }
}

// Interviewer: "What if we add an EV parking fee?"
// Answer: add one class, register it. No existing code changes.
public class EvParkingFee implements FeeStrategy {
    public BigDecimal calculate(BigDecimal amount) {
        return amount.multiply(new BigDecimal("0.005"));
    }
}

Untouched code does not need re-testing and cannot regress, which in banking means no financial loss from a regression.

How do you balance simplicity and extensibility?

The two ideas can seem to pull in opposite directions. Not over-engineering says do not add abstractions on speculation. OCP seems to want abstractions ready for extension. The resolution:

  • Identify the one or two axes where change is genuinely likely. For a bank, new fee types or new account products are likely; new database vendors usually are not.
  • Place a seam (a small interface) only along those likely axes.
  • For unlikely axes, honestly say you would refactor to introduce a seam at that point.
JAVA
package com.bank.demo;

import java.math.BigDecimal;
import java.util.Map;

public class BalanceDemo {
    public static void main(String[] args) {
        // Simple today, open along the likely axis of change
        Map<String, FeeStrategy> fees = Map.of(
            "FLAT", new FlatFee(),
            "PERCENT", new PercentageFee()
        );

        FeeModule module = new FeeModule(fees);
        System.out.println("Flat fee: " + module.calculateFee("FLAT", new BigDecimal("100000")));
        System.out.println("Percent fee: " + module.calculateFee("PERCENT", new BigDecimal("100000")));
    }
}
TEXT
Flat fee: 50.00
Percent fee: 100.000

What are real examples of over-engineering to avoid?

  • Building a plugin architecture with interfaces, a registry, and dynamic loading for a service with only one implementation.
  • Adding a message broker like Kafka when a direct REST call between two services handles the load.
  • Writing a generic configurable rules engine for three fixed rules.
  • Adding caching layers before measuring any performance problem.
  • Creating abstract base classes with a single subclass "in case we need more."

What are the edge cases in LLD simplicity?

Simplicity does not mean ignoring error handling, concurrency, or validation. The edge case is knowing which complexity is essential and which is accidental. A transfer service that skips null-checking or BigDecimal precision because "keep it simple" is not KISS, it is incomplete. The right balance is: no unnecessary abstractions, but all necessary correctness.

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.util.Objects;

public class SimplicityEdgeCases {
    // WRONG: "keep it simple" by skipping validation
    static BigDecimal wrongTransfer(BigDecimal amount) {
        return amount; // No null check, no negative check -- "simple" but broken
    }

    // CORRECT: simple AND correct
    static BigDecimal correctTransfer(BigDecimal amount) {
        Objects.requireNonNull(amount, "Transfer amount must not be null");
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Transfer must be positive: " + amount);
        }
        return amount;
    }

    public static void main(String[] args) {
        System.out.println("Edge case: simplicity != skipping correctness");
        System.out.println("  Null check: essential (not over-engineering)");
        System.out.println("  Positive check: essential (not over-engineering)");
        System.out.println("  Strategy pattern for one variant: over-engineering");
        System.out.println("  Abstract factory for one product: over-engineering");
    }
}
TEXT
Expected output:
Edge case: simplicity != skipping correctness
  Null check: essential (not over-engineering)
  Positive check: essential (not over-engineering)
  Strategy pattern for one variant: over-engineering
  Abstract factory for one product: over-engineering

What should a senior engineer know?

The best candidates demonstrate both halves in one breath:

JAVA
// "Here is the simplest model that meets today's requirements.
//  Because new fee types are a likely change, I put the fee
//  behind a small strategy interface, so adding one later is
//  a new class, not an edit."

That shows you avoid over-engineering while still designing for OCP where it counts. Pankaj calls this "just enough design" at National Bank of India.

Visual Overview

DIAGRAM
  Over-Engineering vs Just Enough Design
  ───────────────────────────────────────

  OVER-ENGINEERED (one fee):
  ┌──────────────┐   ┌───────────────┐   ┌──────────────────┐
  │ FeeCalculator│──▶│ FeeFactory    │──▶│FlatFeeCalculator │
  │ (interface)  │   │ (factory)     │   │(only impl)       │
  └──────────────┘   └───────────────┘   └──────────────────┘
  Three files, one fee. Waste.

  JUST ENOUGH (one fee):
  ┌──────────────────────────────┐
  │ FeeService                   │
  │ FLAT_FEE = 50.00             │
  │ calculateFee(amount) → 50.00 │
  └──────────────────────────────┘
  One file. Done.

  JUST ENOUGH (when second fee arrives):
  ┌──────────────┐
  │ FeeStrategy  │ ◄── interface (seam added now)
  └──────┬───────┘
         ├── FlatFee
         └── PercentageFee (new – no edits to FlatFee)

Key Takeaway

Not over-engineering is the discipline of building the simplest design that correctly meets today's requirements, and it is a core low-level design skill because interviewers judge whether Rajesh knows when not to use a pattern just as much as whether he knows the patterns. Over-engineering shows up as forced patterns where a plain method would do, as a God class that does too many things and changes for too many reasons, as speculative generality built for an imagined future, and as premature abstraction introduced before any real variation exists.

Rajesh's approach at National Bank of India is to start simple and refactor. He writes the cohesive, straightforward model first, keeps each class to a single responsibility, and introduces a pattern only when a real pressure appears: a second variant, a growing switch statement, or a hard-to-test dependency.

The common follow-up is "how would you extend this?" and Rajesh answers it with the Open/Closed Principle: new behaviour is added by writing a new class that plugs into an existing abstraction, not by editing existing, working code. When Pankaj asks how to add a new fee type, Rajesh adds a new class implementing the existing interface and registers it, touching none of the existing classes. That reduces risk because untouched code cannot regress.

The judgment that ties it together is balancing simplicity and extensibility. Rajesh does not pre-build abstractions for every possible future, but he identifies the one or two axes where change is genuinely likely and places a small interface only along those axes. In his notification project he kept the service thin with a single SMS channel and placed one seam, a channel interface. When email and push were later needed, each was a new class with zero edits.

Remember one sentence: start with the simplest correct design, place a seam only where change is genuinely likely, and extend by adding a class, never by editing.

---

Share
Use ← → arrow keys to navigate