Skip to content
CalliCoder

Software Engineering Principles: A Practical Guide

Published Updated System Design 12 min read

KISS, DRY, YAGNI and SOLID are quoted far more often than they are applied. What each one actually claims, the failure mode it prevents, and the cost of following it too literally.

Fit study: notched blocks interlocking, a keyed shaft in its socket, two plates dovetailed.

Most software engineering principles are learned as slogans and applied as reflexes. That is how DRY ends up producing a shared abstraction nobody can change, and how SOLID turns four classes into fourteen. Each of them encodes a real, expensive lesson, and each has a range outside which it makes code worse.

This guide takes the eight that come up most, states what each one actually claims, and is honest about where it stops applying.

Where software development principles come from

Every principle here is a compressed observation about change. Not about correctness, a program can be correct and still be unmaintainable, but about what happens when someone has to modify it eighteen months later without the context you had while writing it.

That framing is the useful test. When you are unsure whether a principle applies, ask what it costs the next person to change this code. If the answer is “nothing much”, the principle is probably not earning its keep here.

KISS, keep it simple

The claim: given two designs that both work, the simpler one is better, because simple code is cheaper to read, test and change.

The trap is that “simple” gets read as “short”. They are not the same thing.

// Short. Not simple.
public static boolean v(String s) {
    return s != null && s.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.{8,}$).*");
}

// Longer. Simpler — each rule is nameable, testable, and reportable to the user.
public record PasswordCheck(boolean ok, List<String> failures) { }

public static PasswordCheck check(String password) {
    if (password == null) return new PasswordCheck(false, List.of("password is required"));

    List<String> failures = new ArrayList<>();
    if (password.length() < 8)                  failures.add("must be at least 8 characters");
    if (password.chars().noneMatch(Character::isLowerCase)) failures.add("needs a lowercase letter");
    if (password.chars().noneMatch(Character::isUpperCase)) failures.add("needs an uppercase letter");
    if (password.chars().noneMatch(Character::isDigit))     failures.add("needs a digit");

    return new PasswordCheck(failures.isEmpty(), failures);
}

The second version is four times the length and enormously simpler: you can read one rule without holding the other three in your head, and it can tell the user which rule failed.

Where it stops applying: simplicity is relative to the reader. A regular expression is simple to someone fluent in them. Optimise for the people who will maintain the code, not for an abstract notion of elegance.

DRY, don’t repeat yourself

The claim: every piece of knowledge should have one authoritative representation. Duplicated knowledge drifts, and a bug fixed in one copy survives in the other three.

The word doing the work is knowledge, not characters. DRY is misapplied constantly because people deduplicate text that merely looks alike.

// These are NOT duplication. They are two rules that currently agree.
boolean canRefund(Order o)  { return o.ageInDays() <= 30; }
boolean canExchange(Order o) { return o.ageInDays() <= 30; }

Collapse those into withinWindow(o, 30) and you have coupled two independent policies. The day returns move to 14 days and exchanges stay at 30, you have to tear the abstraction apart, and whoever does it will not know which callers meant which rule.

The counter-heuristic worth internalising: duplication is cheaper than the wrong abstraction. Two copies that drift apart are a small, local problem. A shared abstraction serving two purposes that have diverged is a refactor across every call site.

Wait for the third occurrence. By then you can see what actually varies.

YAGNI, you aren’t gonna need it

The claim: do not build for requirements you have merely imagined. Speculative generality costs you now and usually guesses wrong.

The classic shape is a strategy interface with one implementation, a factory to select between options that do not exist, and a configuration flag nobody sets: all written because the payment provider “might change one day”. When it does change, the seam almost never falls where you put it.

Where it stops applying: YAGNI governs features, not quality. Tests, error handling, input validation, logging and migration paths are not speculative. You will need every one of them. And decisions that are expensive to reverse later, like a public API’s shape or a database’s primary key strategy, deserve thought up front. Reserve YAGNI for cheap-to-add-later work.

SRP, one reason to change

The claim: a module should have one reason to change. Robert Martin’s later phrasing is sharper: it should answer to one stakeholder.

// Three stakeholders in one class: the tax office, the DBA, and the design team.
class Invoice {
    BigDecimal calculateTax() { ... }   // changes when tax law changes
    void save()               { ... }   // changes when the schema changes
    String toHtml()           { ... }   // changes when the template changes
}

Any of the three can force a change, so all three will keep touching the same file and colliding.

The failure mode in the other direction is real too: a codebase where every class has exactly one method, and following a single request means opening nine files. “One reason to change” is about who requests the change, not about counting methods.

OCP, open for extension, closed for modification

The claim: you should be able to add behaviour without editing code that already works, because editing working code is where regressions come from.

// Every new shape edits this method — and can break the existing branches.
double area(Shape s) {
    if (s instanceof Circle c)     return Math.PI * c.r() * c.r();
    if (s instanceof Rectangle r)  return r.w() * r.h();
    throw new IllegalArgumentException();
}

// Every new shape adds a file. Nothing existing is touched.
interface Shape { double area(); }
record Circle(double r) implements Shape {
    public double area() { return Math.PI * r * r; }
}

Where it stops applying: OCP costs an indirection, and you have to guess the axis of extension correctly. Guess wrong and you get an abstraction that blocks the change you actually need. When the set of variants is genuinely closed, and a sealed interface with exhaustive pattern matching says so to the compiler, the switch is the better design.

LSP, subtypes must honour the contract

The claim: anywhere the base type works, a subtype must work too. It is a statement about behaviour, not about method signatures, which is why the compiler cannot check it.

The textbook case is still the clearest. A Square is a Rectangle mathematically, but if setWidth also changes the height, then this passes for a rectangle and fails for a square:

r.setWidth(5);
r.setHeight(4);
assert r.area() == 20;   // a Square silently makes this 16

Nothing is uncompilable. The subtype simply broke a promise the caller was relying on. The practical tells: an override that throws UnsupportedOperationException, tightens what it accepts, or requires callers to check the concrete type first.

ISP, small interfaces

The claim: clients should not be forced to depend on methods they never call. A fat interface drags its implementers into changes they have no stake in.

The symptom is unmistakable: implementations full of methods that throw, or return null, because that particular implementer has no meaningful answer. When you see that, the interface is really two or three interfaces that were merged.

DIP, depend on abstractions

The claim: high-level policy should not depend on low-level detail. Both should depend on an abstraction, and crucially, the abstraction belongs to the high-level module.

// The interface is defined by the code that USES it, in terms of its own needs.
interface NotificationChannel { void send(UserId to, String message); }

class OrderService {
    private final NotificationChannel channel;
    OrderService(NotificationChannel channel) { this.channel = channel; }
}

OrderService says what it needs. EmailChannel and SmsChannel conform. That direction is the whole point: an interface extracted from an existing implementation, and shaped like it, inverts nothing.

Where it stops applying: an interface with exactly one implementation that exists only to have an interface is pure overhead. Introduce the abstraction when there is a second implementation, a boundary you need to fake in tests, or a genuinely volatile dependency.

How to use this guide in practice

These software development principles are heuristics for a specific situation, a piece of code that is hard to change, and each has a cost. Applying all eight to a hundred-line script produces something worse than the script.

Read the rest of this guide as a set of conditions rather than a checklist. Each entry above names the situation it applies to, and that situation is the thing to look for. A workable order of operations:

  1. Make it work. A correct, ugly, duplicated implementation you understand beats an elegant one you are guessing at.
  2. Let the duplication accumulate. Two copies is data. Three is a pattern you can name.
  3. Apply a principle to a problem you actually have. “This class changes for three unrelated reasons and the merges keep conflicting” justifies SRP. “SRP says so” does not.
  4. Notice the cost. Every principle here trades directness for flexibility. If the flexibility is not being used, you paid for nothing.

What separates the software engineering principles that help from the ones that hurt is almost never the principle itself. It is whether the problem it addresses is present in the code in front of you. The engineers who use these well are not the ones who can recite them. They are the ones who can say why a principle does not apply to the code in front of them.

Frequently asked questions about software engineering principles

What are the main software engineering principles?

KISS, DRY and YAGNI as general heuristics, plus the five SOLID principles (SRP, OCP, LSP, ISP and DIP) which are specifically about object-oriented design.

What does SOLID stand for?

Single responsibility, Open/closed, Liskov substitution, Interface segregation, and Dependency inversion.

Are these software development principles rules?

No. They are heuristics with a range. Each prevents a specific failure and each imposes a cost, so applying one without the failure it prevents is just added indirection.

Does DRY mean I should never repeat code?

It means you should not duplicate knowledge. Two rules that happen to be identical today are not duplication, and merging them couples policies that may diverge.

When should I extract a shared abstraction?

On the third occurrence, when the varying part is visible. The wrong abstraction is more expensive to undo than the duplication was to tolerate.

Doesn’t YAGNI conflict with good design?

No. It applies to speculative features, not to tests, error handling or decisions that are expensive to reverse. Skipping those is not YAGNI. It is skipping the work.

How small should a class be under SRP?

Small enough to have one stakeholder. Counting lines or methods leads to the opposite failure, where following one request means opening nine files.

How do I know I’ve broken LSP?

An override throws UnsupportedOperationException, refuses input the base type accepts, or callers have to check the concrete type before using it.

Two of these have a worked example elsewhere on the site: the singleton pattern is the clearest case of a design that satisfies its own goal and violates dependency inversion, and the layered REST API walkthrough is separation of concerns applied to something concrete.

Do these apply outside object-oriented code?

KISS, DRY and YAGNI apply anywhere. SOLID is framed around classes and inheritance, though SRP and DIP translate cleanly to modules and functions.

Which one matters most in practice?

KISS, by a wide margin. Most damage in a codebase comes from complexity that was added for reasons that no longer apply.