Skip to content
Back to Blog

Clean Code Without the Dogma

Clean code isn't about tiny functions or zero comments. It's about making the next change cheap. The habits I actually keep from the clean code canon, and the ones I quietly dropped.

Sun7 min read

Robert C. Martin's Clean Code came out in 2008, and it's still one of the most recommended and most argued-about books in software. Some people treat it as scripture. Others share blog posts about how its advice makes code worse. Having worked on codebases written by both groups, I've landed somewhere in the middle.

Here's my working definition: clean code is code that's cheap to change safely. Not code that follows the most rules, is shortest, or looks the most elegant. Code where the next developer (usually future you, having forgotten everything) can understand what's going on, make a change, and be confident they didn't break something else.

With that definition, some classic advice holds up well, and some needs an asterisk.

Names do most of the work

If I could keep only one habit, it would be careful naming. A good name makes a comment unnecessary, and a bad one misleads the reader.

// Before
func calc(d []Tx, f bool) float64

// After
func totalRevenue(transactions []Transaction, includeRefunds bool) float64

A few rules I follow:

  • Name things after what they mean, not how they're stored. activeCustomers says more than customerList2 or filteredData.
  • Match the length of a name to its scope. i is fine in a three-line loop. A package-level variable needs a real name. Go's conventions lean on this, which is why short receiver names like s *Server are idiomatic.
  • Avoid boolean parameters that hide intent. send(msg, true) forces the reader to go look up what true means. Two functions (send and sendUrgent) or an options struct is often clearer.
  • Use the business's words. If the domain experts say "policy holder", don't call it user in the code. Translating between vocabularies is where bugs creep in.

There's an old line, usually credited to Phil Karlton, that the two hard things in computer science are cache invalidation and naming things. People laugh at it, but the naming half is the part you deal with every day.

Function size: aim for cohesion, not a line count

The book pushes for very small functions, often just a few lines. Taken literally, that produces code where understanding one operation means jumping through eight functions, each named after a single line of what it does.

What I aim for instead is one level of abstraction per function. A function should read like a clear list of steps at a consistent level of detail:

func (s *Service) Checkout(ctx context.Context, cartID string) (Receipt, error) {
	cart, err := s.carts.Load(ctx, cartID)
	if err != nil {
		return Receipt{}, fmt.Errorf("load cart: %w", err)
	}
	if err := validateStock(ctx, s.inventory, cart); err != nil {
		return Receipt{}, err
	}
	total := priceWithDiscounts(cart, s.promotions)
	return s.payments.Charge(ctx, cart.CustomerID, total)
}

This function is longer than some rules would allow, and that's fine. Every line is at the same level: load, validate, price, charge. You could explain it out loud to a product manager. The detailed work lives in the helpers, where it belongs.

The warning sign isn't "this function has 40 lines". It's "this function mixes high-level steps with low-level details", like the checkout flow above suddenly doing string manipulation on a coupon code in the middle.

Comments explain why, code explains what

The "good code doesn't need comments" camp has a point: a comment that repeats the code is noise, and it goes out of date the first time someone changes the code without updating it.

// Increment the retry count  <- adds nothing
retries++

But some things code can't say, and those need comments:

// The payment provider treats amounts under 50 cents as invalid and
// returns a generic 500 instead of a validation error, so we reject
// them here with a clear message.
if amountCents < 50 {
	return ErrAmountTooSmall
}

That comment records a fact about the outside world that no amount of good naming could express. Business rules, workarounds for other people's bugs, performance trade-offs, reasons you didn't use the obvious approach: those are the comments that save somebody a day of rediscovering them.

DRY is about knowledge, not keystrokes

"Don't repeat yourself" is probably the most misapplied principle in programming. It was originally about knowledge: each business rule should live in one place. It wasn't about deleting every pair of lines that look the same.

Two pieces of code can look identical today and still represent different ideas that will change for different reasons. Merge them into one shared function, and six months later you're adding a boolean flag so it can behave slightly differently for one caller, then another flag, then a third. Sandi Metz put it well: "duplication is far cheaper than the wrong abstraction."

My rule of thumb is the "rule of three". The first time, just write it. The second time, notice the duplication and tolerate it. By the third time, you understand the pattern well enough to extract the right abstraction.

Error handling is part of the design

A lot of clean code discussion stays on the happy path. In production code, the error paths are often half the logic. Clean error handling means:

  • Errors carry context about what was being attempted.
  • Errors are handled once, at the level that can actually do something about them.
  • Expected failures ("not found", "invalid input") are distinguished from unexpected ones ("connection reset").

If you work in Go, I wrote a whole post about this. The principles apply in any language, though.

Tests are part of clean code

Code without tests isn't cheap to change safely, whatever it looks like, because every change is a gamble. Tests are what give you the confidence to refactor at all.

Good tests are clean code too:

  • Name tests after behaviour, like TestCheckout_OutOfStockItem_ReturnsError, not TestCheckout2.
  • Test through public interfaces, so a refactor doesn't break a hundred tests that were checking internal details.
  • Keep each test's setup visible. A test that depends on a 200-line shared fixture is a test nobody can understand when it fails.

What I quietly dropped

  • "Functions should have zero to two arguments." Sometimes a function needs four. An options struct can help, but three well-named parameters are fine.
  • "Never return null / nil." In Go, nil error returns and (value, ok) pairs are the idiom. Clean code follows the language's conventions.
  • Interfaces for everything. An interface with one implementation and no test fake is usually just indirection. Introduce abstractions when you need them, not ahead of time.
  • Refactoring in the same commit as a behaviour change. This one isn't from the book, but it's a trap for keen tidy-uppers. Separate commits make reviews and rollbacks much easier.

The real test

Forget the rules for a moment and ask one question during code review: "If a new team member had to change this next month, how would it go?"

If they could find the right place, understand it, change it, and know from the tests that it still works, the code is clean, whether or not it follows any particular book. If they'd need an hour of explanation, it isn't, however carefully it follows the rules.

As the Go proverb says, clear is better than clever. Most of the time, clear is also what clean means.


Want to talk code quality? Drop me a message on the contact page.