Step 08 of 24

DRY, KISS, YAGNI & CQS

Use coupling and cohesion to judge a design, and apply DRY, KISS, YAGNI, command–query separation and the Law of Demeter without overdoing them.

17 min 9 sections

Design principles are compressed experience: each one names a mistake that teams kept making until someone gave it an acronym. Applied with judgment they keep code simple; applied as laws they produce abstract, over-engineered code that is harder to change than what it replaced. This lesson starts with the two forces underneath every principle — coupling and cohesion — then covers DRY, KISS, YAGNI, command–query separation and the Law of Demeter, including where each one stops helping.

Coupling and cohesion

Every design principle is ultimately about making change cheap. Two measures predict how cheap change will be:

  • Coupling is how much one part of the code depends on another's internals. If changing the orders table means editing the email templates, the invoicing job and three API handlers, those parts are tightly coupled.
  • Cohesion is how strongly the things inside one module belong together. A billing package containing invoices, tax rules and payment retries is cohesive; a utils package with date formatting, HTTP retries and a slug generator is not.

The goal is low coupling, high cohesion: things that change together live together, and things that change for different reasons are separated by narrow interfaces. Keep this pair in mind as you read the rest of the lesson — each principle is a specific way to lower coupling or raise cohesion. The coupling and cohesion note lists the common kinds of coupling from worst to best.

DRY: one source of truth for each piece of knowledge

Don't Repeat Yourself is usually quoted as "don't duplicate code", but its original statement is about knowledge: every piece of knowledge should have a single, authoritative representation in the system. The difference matters.

This is real duplication — one business rule written twice:

go
// in the checkout handler
if order.Total >= 500_000 {
	order.ShippingFee = 0
}

// in the cart preview, written by someone else
if cart.Subtotal() >= 500_000 {
	preview.FreeShipping = true
}

When the free-shipping threshold changes, someone will update one and miss the other, and the cart will promise what checkout doesn't deliver. Give the rule one home:

go
const freeShippingThreshold = 500_000 // in rupiah

func QualifiesForFreeShipping(subtotal int64) bool {
	return subtotal >= freeShippingThreshold
}

This is not real duplication — two pieces of code that look alike but represent different knowledge:

go
func ValidateUsername(s string) error { return checkLength(s, 3, 30) }
func ValidateProductSKU(s string) error { return checkLength(s, 3, 30) }

Today both happen to allow 3–30 characters. If you merge them into one ValidateShortCode, the day marketing wants 40-character SKUs you must either break usernames or untangle the abstraction. Code that is coincidentally similar should stay separate; code that is essentially the same rule should be merged.

Two heuristics keep DRY honest:

  • The rule of three. Tolerate the second copy; extract on the third, when you can see what actually varies.
  • Duplication is cheaper than the wrong abstraction. A shared helper that has grown five boolean parameters to serve five callers is worse than five short copies. When that happens, inline it back into the callers and start again.

KISS: keep it simple

Keep It Simple says that the simplest design that meets the requirements is the best one. Simple doesn't mean easy or short; it means few moving parts and few concepts a reader must hold at once. Complexity sneaks in as:

  • Generic frameworks for a problem with one case ("a pluggable pipeline engine" for one CSV import).
  • Clever code: a one-line chain of map/reduce/bitwise tricks that takes ten minutes to verify.
  • Indirection without payoff: an interface with one implementation and no test that needs a fake, a factory that builds one type.
ts
// Clever
const active = Object.fromEntries(Object.entries(users).filter(([, u]) => u.lastSeen > cutoff).map(([k, u]) => [k, { ...u, active: true }]))

// Simple
const active: Record<string, User & { active: true }> = {}
for (const [id, user] of Object.entries(users)) {
  if (user.lastSeen > cutoff) active[id] = { ...user, active: true }
}

The second version is longer and obviously correct. Prefer obvious.

YAGNI: you aren't gonna need it

YAGNI says: don't build a capability until you actually need it. Speculative features cost you three times — the time to build them, the complexity everyone carries while they sit unused, and the rework when the real requirement turns out different from the guess (it usually does).

Typical YAGNI violations: a plugin system before the second plugin exists, configuration options nobody asked for, multi-tenant support "in case we sell to enterprises", a message queue for a job that runs once a night.

YAGNI is about features and speculative generality, not about quality. It does not mean skipping tests, error handling, or a clean structure that makes later change cheap. In fact, YAGNI only works because of those practices: you can afford not to build the plugin system today because good tests and modular code mean adding it later is cheap. The YAGNI note and when not to add infrastructure apply the same idea at the code and system level.

Command–query separation

Command–query separation (CQS), from Bertrand Meyer, says every method should be either:

  • a query: returns information and changes nothing, or
  • a command: changes state and returns nothing (apart from an error).

Asking a question shouldn't change the answer. Violations create surprises:

go
// Violates CQS: looks like a question, but marks the token as used
func (s *TokenStore) IsValid(token string) bool {
	t, ok := s.tokens[token]
	if !ok || t.Used {
		return false
	}
	t.Used = true // side effect hidden in a query
	s.tokens[token] = t
	return true
}

A teammate adds a log line log.Printf("token valid: %v", store.IsValid(tok)) before the real check, and every login now fails. Separate the query from the command:

go
func (s *TokenStore) IsValid(token string) bool {
	t, ok := s.tokens[token]
	return ok && !t.Used
}

func (s *TokenStore) MarkUsed(token string) error {
	t, ok := s.tokens[token]
	if !ok {
		return ErrUnknownToken
	}
	t.Used = true
	s.tokens[token] = t
	return nil
}

Queries can now be called freely — in logs, in tests, twice — without fear.

There are pragmatic exceptions where combining is the correct design, usually for atomicity: a stack's Pop returns and removes; UPDATE ... RETURNING in SQL changes rows and reports them; an atomic compare-and-swap or "consume this token" must check and change in one step, or two concurrent requests could both pass the check. When you combine, name the method as a command (Consume, Pop, Claim) so nobody mistakes it for a harmless question.

CQRS (command query responsibility segregation) takes the idea from methods to architecture: separate models — sometimes separate databases — for writes and reads. It's a much bigger commitment with real costs; the event sourcing and CQRS note covers when it pays off.

The Law of Demeter

The Law of Demeter ("talk only to your immediate friends") says a method should only call methods on its own fields, its parameters, and objects it creates — not on objects returned by other calls. Its smell is the train wreck:

go
if order.Customer().Account().Wallet().Balance() >= order.Total() { ... }

This line knows that orders have customers, customers have accounts, and accounts have wallets. Restructure any of those and this code breaks. Ask instead of reaching:

go
if order.Customer().CanAfford(order.Total()) { ... }

Now only Customer knows how affordability is computed. The law applies to objects with behaviour, not to plain data: walking a decoded JSON config (cfg.Database.Pool.MaxConns) or a fluent builder isn't a violation, because there's no hidden behaviour to couple to.

Principles in tension

Principles pull against each other, and judgment is choosing between them in context:

TensionHow to resolve it
DRY vs KISSExtract only real shared knowledge; a little duplication beats a contorted helper.
YAGNI vs extensibilityBuild for today's cases, but keep the code modular and tested so tomorrow's is cheap.
CQS vs atomicitySeparate by default; combine when check-then-act must be atomic, and name it as a command.

Practice

Review this Go code. Identify which principles it violates and rewrite it.

go
type Inventory struct{ stock map[string]int }

// Reserve checks stock for a SKU and reserves qty units if available.
func (inv *Inventory) HasStock(sku string, qty int, reserve bool) bool {
	if inv.stock[sku] >= qty {
		if reserve {
			inv.stock[sku] -= qty
		}
		return true
	}
	return false
}
Show solution

Problems:

  • CQS: a method named like a query (HasStock) can change state.
  • Boolean flag parameter: reserve makes the function do two different things, and HasStock("A1", 2, true) is unreadable at the call site.
  • The doc comment names a different function than the one it documents — a sign the code changed and the comment didn't.

Split it into a query and a command. Because "check, then reserve" must not be interleaved with another reservation, the command does its own check and reports failure as an error rather than relying on the caller to call HasStock first:

go
var ErrInsufficientStock = errors.New("insufficient stock")

type Inventory struct{ stock map[string]int }

// Available reports how many units of sku are in stock.
func (inv *Inventory) Available(sku string) int {
	return inv.stock[sku]
}

// Reserve takes qty units of sku out of stock, or returns
// ErrInsufficientStock and changes nothing.
func (inv *Inventory) Reserve(sku string, qty int) error {
	if inv.stock[sku] < qty {
		return ErrInsufficientStock
	}
	inv.stock[sku] -= qty
	return nil
}

If Inventory is used from several goroutines, Reserve also needs a mutex so the check and the update happen as one step — the same atomicity reason a database would use UPDATE ... WHERE stock >= $1.

Key takeaways

  • Aim for low coupling and high cohesion: things that change together live together.
  • DRY is about knowledge, not text: merge copies of the same rule, keep coincidentally similar code apart, and prefer duplication to the wrong abstraction.
  • KISS: the fewest moving parts and concepts that meet the requirement; obvious beats clever.
  • YAGNI: don't build speculative features — but keep the tests and structure that make adding them later cheap.
  • CQS: queries return answers without side effects; commands change state. Combine only for atomicity, and name it as a command.
  • The Law of Demeter: ask an object to do something rather than reaching through its internals.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Two validation functions currently both allow 3 to 30 characters: one for usernames, one for product SKUs. What does DRY say?

  2. 2.Which method follows command–query separation?

  3. 3.A teammate argues that YAGNI means you should skip writing tests until a bug appears. What is wrong with that?

  4. 4.Which line is the clearest Law of Demeter violation?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.