← Back to the section

SOLID and GRASP are about designing classes. The principles in this article are older and broader: they are about engineering decisions in general — from a single line of code to the choice of whether you need one more service at all. They are often quoted as incantations; here is what each of them actually means, where it has its limits and how it looks in code.

DRY — don't repeat knowledge

Imagine the rule "an order over 10,000 ₽ ships for free" is written in three places — in the handler, in a SQL report and in a form on the frontend. The threshold is raised to 15,000 ₽ — three edits were made, and one was forgotten. Users get free shipping when they shouldn't.

DRY (Don't Repeat Yourself) — every piece of knowledge must have a single authoritative place in the system.

The key word is knowledge, not text. DRY is not violated when two pieces of code match textually but carry different knowledge. The length limit for a customer's name and the length limit for a product title may both equal 255 — but these are two independent decisions, and gluing them into one MAX_NAME_LENGTH constant is harmful: changing one will drag the other along.

CUSTOMER_NAME_MAX_LENGTH = 255  # independent decision

PRODUCT_TITLE_MAX_LENGTH = 255  # also independent

Gluing accidentally similar things into one abstraction is the most expensive form of a DRY violation. Later every change requires if-parameters, because the "shared" constant turns out not to be so shared.

A practical rule of thumb is the rule of three: tolerate duplication until the third occurrence. By the third time it becomes clear what in it is shared knowledge and what is just a coincidence.

KISS — among working solutions, pick the simplest

Every extra concept in the code is a tax on every future reader. Asynchronous code on asyncio adds non-trivial constructs and a different debugging style — and is justified only when the load profile requires it. A task queue on a PostgreSQL table with SKIP LOCKED is simpler than a message broker — if there are a thousand events an hour, a broker "for growth" means a separate cluster, monitoring and a team that understands it.

KISS (Keep It Simple) — simplicity is measured by the number of concepts you have to hold in your head to understand the code.

A review test: can you explain the solution to a colleague in a minute? A strategy factory built on metaclasses, hidden behind three protocols, loses to a plain match — even if it is "more extensible".

YAGNI — don't build ahead of need

A developer makes something configurable "for the future", even though there is no second consumer yet. Adds a version field to an API that no one versions. Rolls out a complex multi-layered architecture for a prototype that doesn't yet have a single real user.

YAGNI (You Aren't Gonna Need It) — don't do now what you'll need later. All of this is a bet on the future that usually doesn't pay off, yet demands maintenance already today.

KISS is about the form of the solution, YAGNI is about timing: not "make it simpler", but "don't do it now".

An important boundary: YAGNI is about functionality, not about quality. Tests, clear names and migrations with a rollback strategy will "be needed later" always — the principle does not apply to them.

Separation of Concerns — different aspects in different modules

Code without separation of concerns looks like this: a method in the business logic parses HTTP headers itself, opens a transaction itself, formats the response itself. Change the response format — you touch the business logic. Change the database — you touch the HTTP layer.

Separation of Concerns — different aspects of the system should live in different modules: mixing them makes each of them impossible to change without the risk of breaking the other.

Transactions, security, caching and metrics are separated from the business logic through decorators and middleware. HTTP parsing is separated from processing by the router and Pydantic models. Configuration is separated from code by environment variables and settings objects.

A sign of mixing is an import across the boundary: HTTP types in the business logic, database objects in the HTTP handler.

Law of Demeter — don't reach into the internals of other objects

A common situation: a method gets an order, then reaches into the customer's address, then into the address's city — and so on, several levels deep.

# violation — reaching into the internals through several levels
city = order.customer.address.city

The deeper the chain, the stronger the coupling: OrderService now knows about the internal structure of Customer and Address. Rename a field in Address — the code in OrderService breaks.

Law of Demeter — a method calls methods of its own fields, parameters and local objects, but not of "friends of friends".

# fix — ask the nearest object
city = order.shipping_city()

The shipping_city() method on the Order object knows about the internals — that is its responsibility. OrderService no longer knows anything it shouldn't.

An important caveat: fluent APIs (select(...).where(...).order_by(...) in SQLAlchemy) and transformation chains are not a violation. There every call returns the same builder object or transforms values, rather than reaching into someone else's internals. The law is about structural coupling, not about the number of dots in a line.

Composition over Inheritance — delegate, don't inherit

Imagine an AbstractOrderService with a process method that calls validate. Every subclass overrides validate. Later you need to reuse the validation in another service — and it turns out it is nailed to the hierarchy. Or a new version of the framework comes out, and AbstractOrderService changes the internal order of calls — all subclasses break unexpectedly.

Composition over Inheritance — reuse behavior through delegation, not through class inheritance.

# inheritance — fragile
class BaseOrderService(ABC):

    @abstractmethod
    def validate(self, order: Order) -> None: ...

    def process(self, order: Order) -> None:
        self.validate(order)


# composition — flexible
class OrderProcessor:

    def __init__(self, validator: OrderValidator, repository: OrderRepository) -> None:
        self._validator = validator
        self._repository = repository

    def process(self, order: Order) -> None:
        self._validator.validate(order)
        self._repository.save(order)

Inheritance is the strongest coupling in the language: the subclass depends on the parent's internals, and the contract is easy to break by accident. Composition gives the same reuse through delegation without a fragile hierarchy.

When inheritance is appropriate: extension points that a library or framework itself designed for inheritance (Template Method under an explicit contract). Your own application behavior — always through composition.

Fail Fast — an error should surface early

A None sneaked through five layers and surfaced as an AttributeError in someone else's module on a Friday night in production. Finding the cause took three hours, because the error appeared far from the place where it broke.

Fail Fast — an error should surface as early as possible and as close to its cause as possible.

Three lines of defense:

  • Application startup — an invalid configuration should crash the service at startup, not at runtime in production.
  • Request boundary — input data is validated before the business logic.
  • Object constructor — an object must not exist in an invalid state.
@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: Currency

    def __post_init__(self) -> None:
        scale = -self.amount.as_tuple().exponent
        if scale > self.currency.fraction_digits:
            raise ValueError(
                f"scale {scale} exceeds the allowed value for {self.currency}"
            )

The opposite is "defensive" swallowing: except Exception as e: logger.error(e); return None. Such code turns an early error into a late one and destroys the context that would have helped diagnose it.

Principle of Least Astonishment — the code does what it promises

A method is called get_order(), but it also changes the order's status along the way. A GET endpoint with a side effect. An __eq__ that compares by identity on a value type. All of this works, but it lies to the reader.

Principle of Least Astonishment — the code should do what the reader expects; surprise is a sign of a design defect.

The cost is not aesthetics: code that can't be trusted by its signature has to be read in full, by everyone, every time.

Conventions are the applied form of the principle: query methods must not change state; a GET request must be safe, and a PUT must be idempotent. Breaking these conventions breaks the caches and retry logic that rely on them.

The cheapest way to honor the principle is to follow the conventions of the stack rather than invent your own: every "we do it differently here" is a future surprise.

Occam's razor — don't multiply entities beyond necessity

A second microservice when there is one domain and one team. Redis at a hundred requests per second, "because scaling". A separate shared module for the sake of three utilities. A message broker for three events a day.

Occam's razor in design: every entity — a service, a broker, a database, a library, a layer, a pattern — must justify its existence.

In debugging it works the same way: among the hypotheses "a bug in the runtime", "a bug in the framework", "a bug in my yesterday's commit", it's worth starting with the last one — the simplest explanation more often turns out to be the right one.

KISS is about the form of the code, Occam's razor is about the composition of the system. Both are about economy, at different levels.

In short

  • DRY — one piece of knowledge in one place; an accidental match of text is not a DRY violation.
  • KISS — among working solutions, pick the one that is easier to explain; every concept is a tax on the reader.
  • YAGNI — don't build ahead of a real need; it doesn't apply to tests and clear names.
  • Separation of Concerns — different aspects in different modules; an import across the boundary is a sign of mixing.
  • Law of Demeter — a method works with the nearest objects, doesn't reach through a chain into the internals; fluent APIs are not a violation.
  • Composition over Inheritance — reuse through delegation; inheritance is appropriate only where the framework was explicitly designed for it.
  • Fail Fast — an error is cheaper when caught early; the constructor, the request boundary, the application startup are the three lines of defense.
  • Least Astonishment — the code does what it promises by its signature and by the conventions of the stack.
  • Occam's razor — every entity in the system must justify its existence.
  • SOLID by example — the same ideas at the level of a class's design.
  • GRASP by example — whom to assign responsibility in an object system.
  • Monolith, modular monolith or microservices — Occam's razor at the architectural level.