Engineering with SOLID, DRY, KISS, YAGNI and GRASP

Foundation of Principles

Software systems age. What starts as a clean design often becomes tangled as requirements shift, teams grow, and features evolve. At that point, the cost of change is no longer measured in lines of code — it’s measured in hesitation, risk, and regression.

Engineering principles aren’t about elegance or ideology. They exist to preserve clarity, adaptability, and control as complexity compounds. Principles like SOLID, DRY, and GRASP don’t guarantee good architecture — but they provide mental scaffolding for making decisions that scale.

What unites these principles is their focus on structure over syntax, responsibility over mechanics, and intent over implementation. They don’t prevent failure. They help make failure visible — early, local, and recoverable.

That distinction separates professional engineering from tactical programming: the former builds for change, the latter builds until change breaks it.

Structure as Strategy: On SOLID

When systems grow, the challenge shifts from “does it work?” to “can we still work with it?” That’s where SOLID enters — not as a checklist, but as a set of forces pulling design toward modularity, predictability, and resilience.

The five SOLID principles were first articulated in the early 2000s by Robert C. Martin (Uncle Bob), building on object-oriented design practices from the 1990s. They offered a response to a rising problem in software: code that worked, but couldn't evolve. Since then, SOLID has become a cornerstone of maintainable software architecture — not because it's perfect, but because it exposes tension between structure and change.

Each principle addresses a different kind of fragility:

  • classes that change for too many reasons,

  • code that breaks when extended,

  • interfaces that assume too much,

  • hierarchies that don’t behave as promised,

  • and modules too entangled to evolve independently.

Individually, each principle mitigates a failure mode. Together, they define a posture: design should accommodate change, isolate risk, and expose intent. SOLID is not about correctness — it's about sustainability under change.

What makes it powerful is not rigidity, but guidance. It's not that you must follow every letter — it's that violations signal pressure points in your architecture.

(S) Single-Responsibility Thinking

A class has one reason to change — that’s the rule. But what counts as a “reason”? It's not a method count or lines of code. It’s a shift in the system's expectations of that class.

If a module handles data formatting, business validation, and database interaction — it doesn't have three methods, it has three axes of volatility.

The Single Responsibility Principle isn’t about reducing size — it’s about reducing collateral damage. The smaller the surface area of responsibility, the easier it is to:

  • understand what the code is for,

  • reason about what might change,

  • test behavior in isolation,

  • and replace or refactor without regressions.

It’s also a communication tool. When structure mirrors responsibility, intent becomes visible. You don’t have to guess what a class does — its name, shape, and interface speak for it.

🟢 Refactored with SRP in mind

🔴 Example in Python: SRP Violation

class Report:
    def generate(self):
        # logic to generate report data
        ...

    def save_to_file(self):
        # logic to save report to disk
        ...
class ReportGenerator:
    def generate(self):
        # generates report content
        return "report data"

class FileSaver:
    def save(self, report):
        # saves report to disk
        ...

Each class now has one axis of change. This makes them easier to reuse, test, and replace. And it sets up clear composition: something else can coordinate them — without entangling their roles.

(O) Open for Extension, Closed for Modification

Change is constant — but not all change is equal. Some changes reflect growth: new features, new types, new rules. Others are corrections: edits to existing logic, rewrites of behavior you thought was stable. The Open/Closed Principle exists to separate those forces.

It suggests that modules should be open for extension — meaning new behavior can be added — but closed for modification — meaning existing code doesn’t need to be touched.

The goal isn’t to prevent change — it’s to localize it, to make growth additive rather than disruptive.

Common signs of violation:

  • conditionals branching by type or value (if, switch, etc.),

  • ever-growing else if chains,

  • changing core logic every time a new use case appears.

Proper structure turns those branching points into dispatch, inheritance, or delegation — so that the core system stays fixed while new logic arrives through new types.

🔴 Example in Python: OCP Violation

def send_notification(type, message):
    if type == "email":
        print(f"Sending email: {message}")
    elif type == "sms":
        print(f"Sending SMS: {message}")
 

Every time a new channel is added (e.g. push notification, Slack, webhook),
this function has to be modified — violating the principle.

🟢 Refactored: OCP-Compliant Design

class Notifier:
    def send(self, message):
        raise NotImplementedError

class EmailNotifier(Notifier):
    def send(self, message):
        print(f"Sending email: {message}")

class SMSNotifier(Notifier):
    def send(self, message):
        print(f"Sending SMS: {message}")

def notify_all(notifiers, message):
    for notifier in notifiers:
        notifier.send(message)

Every time a new channel is added (e.g. push notification, Slack, webhook), this function has to be modified — violating the principle.

(L) Liskov Substitution Principle

Inheritance is easy to use — and easy to misuse. A subclass may look like a drop-in replacement for its parent, but if it doesn’t preserve expected behavior, it silently violates the structure of the system.

The Liskov Substitution Principle — introduced by Barbara Liskov in 1987 — states that objects of a superclass should be replaceable with objects of a subclass without affecting correctness. It’s a behavioral contract, not a syntactic one.

A violation often goes unnoticed until it breaks downstream logic.

Signs of violation:

  • a subclass removes or disables expected behavior from the parent class

  • a method in the subclass changes the meaning or outcome of the original contract

  • using the subclass leads to unexpected results, errors, or defensive checks in client code

These issues break polymorphism — not in compilation, but in intent.

🔴 Example in Python: Violation of LSP

class Storage:
    def write(self, data):
        raise NotImplementedError

class LocalDisk(Storage):
    def write(self, data):
        print("Writing to local disk...")

class ReadOnlyStorage(Storage):
    def write(self, data):
        raise Exception("This storage is read-only")

This violates LSP. Client code relying on Storage expects .write() to succeed — or at least perform an operation. A subclass that disables that behavior breaks the substitution guarantee.

 

🟢 Refactored: LSP-Compliant Design

class ReadableStorage:
    def read(self):
        raise NotImplementedError

class WritableStorage(ReadableStorage):
    def write(self, data):
        raise NotImplementedError

class LocalDisk(WritableStorage):
    def write(self, data):
        print("Writing to local disk...")

class BackupArchive(ReadableStorage):
    def read(self):
        print("Reading from archive...")

Now the design clearly distinguishes read-only and writable storages. There’s no substitution trap: client code interacts only with the contract it actually needs.

(I) Interface Segregation Principle

Interfaces are contracts — and like all contracts, they should be clear, focused, and minimal. The Interface Segregation Principle states that no client should be forced to depend on methods it doesn’t use.

This is not just about classes — it's about coupling. A bloated interface couples unrelated concerns. Any change in that interface ripples through all implementers, regardless of whether it affects them.

Violations often start with good intentions: grouping related functionality together. But over time, a single interface becomes a dumping ground for everything a “thing” might do — instead of a precise definition of what a component must do.

Signs of violation:

  • an interface declares methods that only some clients use

  • implementers are forced to add no-op or dummy logic to satisfy unused methods

  • changes to the interface trigger modifications across unrelated parts of the system

🔴 Example in Python: Violation of ISP

class DocumentProcessor:
    def print(self, doc):
        pass

    def scan(self):
        pass

    def fax(self, number):
        pass

This interface assumes all processors can print, scan, and fax — but many real-world devices (or test doubles) can’t.
Clients using only one capability are now tied to all three — and implementers must stub out what doesn’t apply.

 

🟢 Refactored: Focused Interfaces

class Printable:
    def print(self, doc):
        raise NotImplementedError

class Scannable:
    def scan(self):
        raise NotImplementedError

class Faxable:
    def fax(self, number):
        raise NotImplementedError

class SimplePrinter(Printable):
    def print(self, doc):
        print("Printing:", doc)

Now clients and implementers work only with the capabilities they need. The system becomes more composable, more testable, and easier to evolve. Interface segregation is how you model behavioral precision — keeping dependencies tight and intent explicit.

(D) Dependency Inversion Principle

High-level modules define what a system does. Low-level modules define how it’s done.
The Dependency Inversion Principle says that high-level logic should not depend on low-level details — they should both depend on abstractions.

Without this separation, changes in infrastructure — logging, storage, APIs, UI — ripple upward into business logic. That breaks isolation and erodes trust: when a small technical shift affects strategic code, teams hesitate to touch anything.

Dependency inversion flips the default structure. Instead of injecting low-level dependencies into high-level code, we define interfaces that express needs, and supply implementations from the outside.

Signs of violation:

  • high-level code directly creates or manages low-level components

  • changes in infrastructure force updates in core business logic

  • implementation details are entangled with logic that should be technology-agnostic

🔴 Example in Python: Violation of DIP

class App:
    def __init__(self):
        self.db = MySQLDatabase()

    def run(self):
        self.db.connect()

This creates a hard dependency on MySQLDatabase.
There’s no way to reuse or test
App with a different backend without modifying its code.

 

🟢 Refactored: Inversion via Abstraction

class Database:
    def connect(self):
        raise NotImplementedError

class MySQLDatabase(Database):
    def connect(self):
        print("Connecting to MySQL")

class App:
    def __init__(self, db: Database):
        self.db = db

    def run(self):
        self.db.connect()

Now App depends only on the interface, not the implementation. The actual database can be injected from the outside — enabling testing, substitution, and scaling across backends.

Dependency Inversion is not about using interfaces for everything — it’s about preserving boundaries between what drives the system and what executes it.
It turns rigid layers into composable systems — where orchestration is driven by purpose, not wiring.


DRY: Don’t Repeat Yourself

Duplication is rarely about lines of code — it’s about repeating decisions in disconnected places. When the same rule appears in a database trigger, in application logic, and again in frontend validation — it’s not just verbose, it’s brittle. Every copy becomes a liability: if one is wrong, which one should be trusted?
The DRY principle isn’t about removing repetition for its own sake. It’s about keeping knowledge centralized, so that meaning can evolve without fragmentation.

The principle was introduced in 1999 in The Pragmatic Programmer, with a simple idea: every piece of knowledge should have a single, unambiguous representation in the system. When that’s violated, the software becomes fragile.
Not because duplication is slow — but because it’s unclear where truth lives.

DRY is about alignment:

  • between logic and structure,

  • between documentation and behavior,

  • between what the system does and what it says it does.

It’s not a formatting rule. It’s a strategic design tool. The less duplication, the easier it is to change, to reason, and to trust.

Signs of violation:

  • the same logic or rule appears in multiple components or layers

  • a single change requires touching several unrelated files

  • team discussions reveal inconsistent interpretations of the same behavior

  • developers hesitate to refactor because they’re unsure which copy matters

🔴 Example in Python: DRY Violation

def calculate_discount(user_type, total):
    if user_type == "premium":
        return total * 0.8
    elif user_type == "standard":
        return total * 0.9
    return total

def send_invoice_email(user_type, total):
    if user_type == "premium":
        discounted = total * 0.8
    elif user_type == "standard":
        discounted = total * 0.9
    else:
        discounted = total
    # send email with discounted total

The discount logic is duplicated — changes must be made in both places.
This increases the risk of drift and introduces hidden inconsistencies.

 

🟢 Refactored: Single Source of Truth

def calculate_discount(user_type, total):
    if user_type == "premium":
        return total * 0.8
    elif user_type == "standard":
        return total * 0.9
    return total

def send_invoice_email(user_type, total):
    discounted = calculate_discount(user_type, total)
    # send email with discounted total

Now the rule is defined once, and reused where needed.
This makes the system easier to change, test, and understand — without guessing which copy is correct.

What’s not a violation

  • code that looks similar but evolves for different reasons

  • intentional duplication in tests to keep them explicit and isolated

  • repeated structure that exists to separate concerns or reduce coupling

  • domain boundaries where shared logic would create tight, fragile dependencies

DRY is not about minimizing repetition at any cost. Sometimes, keeping logic local — even if similar — leads to better autonomy, testability, and long-term clarity.

Start with knowledge, not syntax. DRY applies first and foremost to things that encode meaning — calculations, rules, mappings, configurations — not to every pair of similar lines.

Focus on centralizing what the system depends on:

  • business logic that drives decisions

  • thresholds, formulas, and transformation rules

  • permission models, status workflows, currency conversions

When repetition starts to appear, ask:

  • do these things mean the same thing?

  • will they always change together?

  • is it safe to unify them without creating coupling across boundaries?

Don’t DRY everything. DRY the things that matter.


KISS: Keep It Simple, Stupid

Complexity is seductive. Abstractions, indirection, patterns — they feel like progress. But unless the problem truly demands it, added complexity becomes friction: harder to read, harder to test, harder to trust.

The KISS principle doesn’t mean writing simplistic code. It means building systems that are only as complex as they need to be — and no more. Simpler systems are easier to change, easier to debug, and harder to break.

Most complexity comes not from hard problems — but from overengineering:

  • solving general cases too early,

  • layering abstractions before behavior stabilizes,

  • optimizing what doesn’t hurt.

KISS is about delayed ambition. First, solve the problem. Then, improve the structure — if it becomes necessary.

Elegance is not how much you add. It’s how much you can leave out.

Signs of violation:

  • multiple layers of abstraction without clear benefit or reuse

  • generic code that handles cases which don’t exist (yet)

  • deep object hierarchies where a flat structure would suffice

  • logic broken into micro-functions that obscure, rather than clarify, intent

🔴 Example in Python: KISS Violation

class Operation:
    def execute(self):
        raise NotImplementedError

class Add(Operation):
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def execute(self):
        return self.x + self.y

class Calculator:
    def run(self, operation: Operation):
        return operation.execute()

result = Calculator().run(Add(2, 3))

The structure mimics a command pattern — but adds no value for a simple calculation.
There’s
more indirection than logic, and the reader has to trace through layers to understand what's happening.

 

🟢 Refactored: KISS Applied

def add(x, y):
    return x + y

result = add(2, 3)

Same behavior. Clearer intent.
Unless there's a real need to support dynamic operation dispatch or extensibility — this is what KISS demands:
minimal structure for maximal clarity.

What’s not a violation

Some tasks are complex by nature — and trying to “simplify” them makes things worse. Code that looks complex may still be clear, predictable, and correct — if it reflects the true shape of the problem. KISS is not about writing the shortest code. It’s about avoiding unnecessary complexity — not all complexity.

How to apply it well

Start with the problem — not the abstraction. Don’t build for flexibility you don’t need. Don’t generalize what hasn’t repeated. And don’t break things apart until they ask to be separated. The best way to keep things simple is to write them in a way that’s easy to read, easy to change, and hard to misunderstand. When in doubt, remove something. If the code still works — and reads better — that complexity didn’t belong in the first place.


YAGNI: You Aren’t Gonna Need It

Most systems don’t fail from what they lack — they fail from what they don’t actually need.
YAGNI exists to protect code from imaginary futures: from features nobody uses, flexibility nobody asks for, and complexity that solves problems no one has.

The principle comes from extreme programming, but its message is universal: don’t build something until it becomes necessary. Not “potentially useful.” Not “probably needed.” Necessary.

Adding flexibility feels responsible. It looks like planning ahead. But in practice, it’s speculative risk:

  • code that must be maintained but never executed,

  • APIs designed to support scenarios that never materialize,

  • effort spent generalizing something that never repeats.

YAGNI is not about being short-sighted. It’s about keeping change grounded in evidence — not guesses.

What matters isn’t whether you can build it. It’s whether you should build it now.

Signs of violation:

  • code handles edge cases that don’t exist in current requirements

  • interfaces are designed to support future extensions that aren’t planned

  • abstractions appear before the second concrete use case

  • development time is spent solving problems no user has reported

YAGNI violations usually don’t look wrong — they look “smart” and “prepared.” That’s why they’re dangerous: they accumulate quietly, until the cost of unused code outweighs its imagined benefit.

How to apply it well

Design for what’s real — not for what might happen. If a new case emerges, solve it when it arrives. If a second variant appears, then extract a shared abstraction. Until then — write only what’s needed to make the system work today. Resist the urge to “prepare for scale,” “support future types,” or “generalize early.” Most of those futures never arrive — but their complexity stays behind.

The code you don’t write is the easiest to maintain.


GRASP: Patterns for Assigning Responsibility

Most design principles tell you what to value: simplicity, modularity, consistency. GRASP goes further — it helps decide where things belong. The acronym stands for General Responsibility Assignment Software Patterns — a set of principles for structuring responsibility in object-oriented design.

It answers the structural questions that shape maintainability:

  • Who creates what?

  • Who controls what?

  • Who knows what?

  • Who should handle which operation?

Introduced by Craig Larman in the late 1990s, GRASP defines nine foundational patterns that guide responsibility assignment in object-oriented systems.
They don’t prescribe structure — they inform decisions about coupling, cohesion, and delegation.

These patterns aren’t rules. They’re tools: used to resolve trade-offs and build systems where responsibilities are clear, distributed, and scalable.

These nine patterns form the GRASP set: Information Expert, Creator, Controller, Low Coupling, High Cohesion, Polymorphism, Pure Fabrication, Indirection, and Protected Variations.

Each addresses a different kind of decision — from object creation to behavioral flexibility — helping distribute logic in a system that grows without entangling.

Information Expert

The Information Expert pattern assigns responsibility to the class that has the necessary data to fulfill it. Instead of centralizing logic or scattering operations across unrelated components, this pattern keeps behavior close to the data it relies on. It’s a natural fit in most object-oriented designs. When objects are responsible for their own state and behavior, code becomes easier to reason about — and less dependent on external orchestration.

This also helps with encapsulation: internal details stay hidden, and consumers don’t need to manually assemble logic from raw data. Information Expert doesn’t mean “put everything in the object.” It means: who knows enough to do this well, without asking others?

Creator

The Creator pattern answers a simple question: who should create an object? Its guidance: assign creation responsibility to a class that is closely related to the thing being created — by aggregation, composition, or direct use. This leads to stronger cohesion and fewer dependencies.

For example, if an Order contains OrderItems, it makes sense for Order to create them. It already manages them, stores them, and depends on their existence.

Creator helps avoid factories too early and avoids spreading new keywords across unrelated classes. It also reduces the need for wiring and configuration in small systems — creation happens where the relationship is natural. When object creation is tightly aligned with usage, the system becomes simpler, clearer, and easier to evolve.

Controller

The Controller pattern defines where to handle system-level input — user actions, API calls, external events. It assigns this responsibility to a non-UI class that represents a use case, transaction, or system boundary. Without a controller, input-handling logic tends to leak into UI code or get distributed across domain models. That creates tight coupling and makes orchestration hard to test or change.

A controller acts as an entry point:

  • it interprets incoming requests,

  • coordinates collaborators,

  • and delegates actual work to domain objects.

It doesn’t do the work itself — it decides who should. Used well, the controller becomes the thin layer where intent is translated into action — keeping input logic isolated, testable, and replaceable.

Low Coupling

Low Coupling means keeping components as independent as possible. When one module changes, others shouldn’t break. When something fails, the blast radius should be small. Coupling is often invisible — it hides in assumptions, shared formats, global state, and indirect references. The more a class knows about others, the harder it is to move, test, or reuse it. Low Coupling doesn’t mean no dependencies. It means depending only on what you actually need — and nothing more.

It’s a balance:

  • fewer connections,

  • simpler interfaces,

  • and clear boundaries.

You don’t always need loose coupling. But when you do — you’ll wish you designed for it.

High Cohesion

A highly cohesive class does one thing — and does it well. Its methods and data work toward a common goal. Nothing feels out of place.

When cohesion is high:

  • the purpose of the class is obvious,

  • changes affect a single part of the system,

  • and understanding the code doesn’t require jumping between files.

Low cohesion makes code noisy. It mixes unrelated logic and hides intent — making everything harder to read, test, and change. Cohesion isn’t about size. It’s about clarity: everything here belongs together.

Polymorphism

Polymorphism is the ability to handle different types through a shared interface. Instead of branching by type or value, the system relies on interchangeable behavior — each implementation knowing how to act in its own way.

This pattern reduces the need for conditionals and switch statements scattered across the codebase. It allows variation to be handled by delegation, not by logic trees — and that keeps the core of the system stable as new cases are added.

Polymorphism is most effective when the system needs to support alternatives that share a role but differ in behavior — like storage backends, payment providers, or message formats.
Instead of rewriting logic to accommodate each variant, the system just calls the interface — and trusts the implementation to do the right thing.

class PaymentProcessor:
    def process(self, amount):
        raise NotImplementedError

class StripeProcessor(PaymentProcessor):
    def process(self, amount):
        print(f"Processing ${amount} via Stripe")

class PayPalProcessor(PaymentProcessor):
    def process(self, amount):
        print(f"Processing ${amount} via PayPal")

def checkout(processor: PaymentProcessor, amount):
    processor.process(amount)

# Polymorphic usage:
checkout(StripeProcessor(), 100)
checkout(PayPalProcessor(), 200)

Pure Fabrication (not to be confused with the Factory pattern)

Not all responsibilities fit neatly into real-world objects. Sometimes, forcing logic into domain classes leads to bloated models and tangled concerns. Pure Fabrication means introducing a class that doesn’t represent anything in the business domain, but exists to support architectural clarity — by isolating responsibilities, improving cohesion, or enabling reuse.

A Logger, Repository, or EmailSender isn’t part of the business itself. It’s an artificial construct — fabricated — to keep technical concerns out of the core model.

This pattern reminds us: not every class must mirror the real world. Some exist to protect the parts that do.

Indirection

Sometimes, two parts of the system shouldn’t talk to each other directly — even if they technically could. Their connection creates rigidity: a change in one forces a change in the other. That’s where Indirection comes in. This pattern introduces an intermediate — a layer, interface, or mediator — that decouples collaborators. It allows them to evolve independently, be replaced, or tested in isolation.

Indirection adds complexity — but purposefully. It’s not about layering for its own sake. It’s about controlling dependency paths, so that the system stays flexible as it grows.
The question isn’t “can these two parts communicate?” It’s “what happens to the rest of the system if they do?”

Protected Variations

Some parts of a system are volatile by nature. APIs change. Rules evolve. Technologies shift. Protected Variations is about isolating these unstable points behind stable interfaces — so the rest of the system stays intact.

This pattern doesn’t prevent change — it absorbs it. By placing a “protective layer” between the system and the volatility, it ensures that impact is localized and predictable.

Often it takes the form of:

  • an interface hiding a third-party dependency,

  • a domain service wrapping unstable business logic,

  • a gateway between layers or subsystems.

Protected Variations isn’t about abstraction for abstraction’s sake. It’s about defending the system from avoidable damage when change inevitably comes.


Principles, Not Rules

Design principles aren’t rules to follow — they’re tensions to manage. Each principle points at a trade-off: coupling vs reuse, simplicity vs flexibility, abstraction vs clarity. There’s no formula. Only context. You don’t apply SOLID, DRY, KISS, YAGNI, or GRASP to make code “correct.” You use them to make better decisions — under pressure, in legacy, across teams, with change coming.

Sometimes duplication is safer than abstraction. Code reuse isn’t worth it when it forces unrelated modules to share logic they don’t need. Simple code isn’t the shortest — it’s the one that still behaves correctly when the system is under load or change.

The point isn’t to memorize patterns. It’s to recognize the shape of a problem — and reach for the right tension, at the right time.

Previous
Previous

You Can’t Trust COUNT and SUM: Scalable Data Validation with Merkle Trees

Next
Next

Kubernetes Best Practices — Deployment and Troubleshooting