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 MaxNameLength constant is harmful: changing one will drag the other along.
// package customer
const NameMaxLength = 255 // independent decision
// package product
const TitleMaxLength = 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. Reactive programming adds non-trivial types 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 generic strategy factory with reflection, hidden behind three interfaces, loses to a plain switch — 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 middleware and wrappers. HTTP parsing is separated from processing by the handler and codecs. Configuration is separated from code by environment variables and config files.
A sign of mixing is an import across the boundary: HTTP types in the business logic, database objects in the controller.
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.ShippingCity()
The ShippingCity() 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 (builder.WithTimeout(...).Build()) and transformation pipelines are not a violation. There every call returns the same builder 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 a base type BaseOrderService with a Process method that calls Validate. Every service embeds the base, expecting to "override" Validate. Later you need to reuse the validation in another service — and it turns out it is nailed to the base type. And in Go embedding doesn't even give you overriding: Process will still call the base Validate.
Composition over Inheritance — reuse behavior through delegation, not through imitating inheritance with embedding.
// imitating inheritance with embedding — fragile
type BaseOrderService struct{}
func (s *BaseOrderService) Validate(order *Order) error { /* ... */ }
func (s *BaseOrderService) Process(order *Order) error {
return s.Validate(order) // always calls the base Validate — it can't be overridden
}
// composition — flexible
type OrderProcessor struct {
validator OrderValidator
repository OrderRepository
}
func (p *OrderProcessor) Process(order *Order) error {
if err := p.validator.Validate(order); err != nil {
return err
}
return p.repository.Save(order)
}
Embedding that imitates inheritance is the strongest coupling: the outer type depends on the internals of the embedded one, and the contract is easy to break by accident. Composition gives the same reuse through delegation without a fragile hierarchy.
When embedding is appropriate: extension points that a library itself designed for embedding (for example, UnimplementedGreeterServer in gRPC). Your own application behavior — always through composition.
Fail Fast — an error should surface early
A nil sneaked through five layers and surfaced as a panic: nil pointer dereference 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.
type Money struct {
amount decimal.Decimal
currency Currency
}
func NewMoney(amount decimal.Decimal, currency Currency) (Money, error) {
if currency == "" {
return Money{}, errors.New("currency is required")
}
if int(-amount.Exponent()) > currency.FractionDigits() {
return Money{}, fmt.Errorf("scale %d exceeds the allowed value for %s",
-amount.Exponent(), currency)
}
return Money{amount: amount, currency: currency}, nil
}
The opposite is "defensive" swallowing: if err != nil { log.Error(err); return nil, nil }. 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 GetOrder(), but it also changes the order's status along the way. A GET endpoint with a side effect. An Equal method that compares by pointer 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; embedding is appropriate only where the library 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.
What to read next
- 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.