Book summary · Software craftsmanship · Kotlin

Clean Code, Distilled: What I Learned from Robert C. Martin’s Classic

Khairul Alam·October 2026·~40 min read
Clean CodeBook summaryKotlinRefactoring

I read Clean Code by Robert C. Martin (“Uncle Bob”) and it changed how I name things, how long I let functions grow, and how I feel about comments. This post is my own summary of the ideas that stuck with me. For each one I show a bad and a clean version in Kotlin, because the book’s examples are in Java and a principle is easier to remember when you can see the difference.

The whole post in 60 seconds
  • Code is read far more often than it is written, so write for the reader.
  • Names should say what a thing is or does. Magic numbers get names too.
  • Functions should be small, do one thing, and avoid flag arguments and hidden side effects.
  • Prefer clear code over comments. Keep comments for intent and warnings.
  • Hide internals, and tell objects what to do instead of reaching through them.
  • Tests are code too: keep them readable, fast, independent and focused.
  • Use exceptions with context, wrap third-party code, keep classes focused, build objects in one place, and share as little mutable state as you can.
  • Leave every file a little cleaner than you found it.
Contents
  1. About the book and where to get it
  2. The big idea: write for the reader (Chapter 1)
  3. Meaningful names (Chapter 2)
  4. Functions (Chapter 3)
  5. Comments (Chapter 4)
  6. Formatting (Chapter 5)
  7. Objects, data and the Law of Demeter (Chapter 6)
  8. Error handling (Chapter 7)
  9. Boundaries (Chapter 8)
  10. Unit tests (Chapter 9)
  11. Classes (Chapter 10)
  12. Systems (Chapter 11)
  13. Emergence (Chapter 12)
  14. Concurrency (Chapter 13)
  15. Successive refinement (Chapter 14)
  16. JUnit internals (Chapter 15)
  17. Refactoring SerialDate (Chapter 16)
  18. Smells and heuristics (Chapter 17)
  19. Conditionals and understandability (from Chapter 17)
  20. Where I use judgement
  21. Takeaways, checklist and references

1. About the book and where to get it

Clean Code: A Handbook of Agile Software Craftsmanship was written by Robert C. Martin with contributions from several co-authors and published by Prentice Hall (Pearson) in 2008. It has three parts: principles and patterns for writing good code, long case studies that clean up real code step by step, and a final catalogue of “smells” and heuristics. The examples are in Java.

DetailValue
TitleClean Code: A Handbook of Agile Software Craftsmanship
AuthorRobert C. Martin
ISBN-10 / ISBN-130132350882 / 978-0132350884
Buy the bookAmazon · InformIT (publisher’s store)

About “downloading”: the book is under copyright, so the official ways to get it are to buy a print copy or an authorised e-book from the links above. I do not link to free copies. If you want a short checklist of the rules first, the community Clean Code summary on GitHub by wojteklu is a good outline, and it shaped the order of this post.

This is my own retelling in my own words, with my own Kotlin examples. It is a map, not a substitute: the book’s long refactoring walk-throughs are where most of the learning happens.

Where each chapter is in this post

Book chapterSection here
1. Clean CodeThe big idea
2. Meaningful NamesMeaningful names
3. FunctionsFunctions
4. CommentsComments
5. FormattingFormatting
6. Objects and Data StructuresObjects, data and the Law of Demeter
7. Error HandlingError handling
8. BoundariesBoundaries
9. Unit TestsUnit tests
10. ClassesClasses
11. SystemsSystems
12. EmergenceEmergence
13. ConcurrencyConcurrency
14. Successive RefinementSuccessive refinement
15. JUnit InternalsJUnit internals
16. Refactoring SerialDateRefactoring SerialDate
17. Smells and HeuristicsSmells and heuristics, Conditionals and understandability

2. The big idea: write for the reader (Chapter 1)

The book’s starting point is a simple observation: we spend much more time reading code than writing it, because every change begins with reading what is already there. Martin puts the ratio at well over ten to one. So anything that makes code quicker to understand pays off many times over.

Reading code Writing code The book’s rule of thumb: roughly ten parts reading for every one part writing
An illustration of the ratio the book describes, not a measurement.

Three general rules follow, and I keep them in my head more than any other:

3. Meaningful names (Chapter 2)

A name is a tiny comment that cannot go out of date, if you choose it well. The book asks for names that reveal intent, are pronounceable and searchable, and do not make the reader decode abbreviations. It also asks for real differences: data and info, or getAccount and getAccountInfo, tell the reader nothing about how they differ.

Bad

fun calc(d: List<Int>, t: Int): Int {
    var r = 0
    for (x in d) {
        if (x > t) r += x
    }
    return r
}

Clean

fun totalOfOrdersAbove(minimum: Int, orderTotals: List<Int>): Int =
    orderTotals.filter { it > minimum }.sum()

Same behaviour. The second version tells you what it computes before you read a line of the body. Short names are fine in tiny scopes (a loop index, a lambda’s it), but the wider the scope, the more descriptive the name should be.

Magic numbers get names

// Bad
if (user.age >= 18 && user.status == 2) { allowPurchase() }

// Clean
const val ADULT_AGE = 18
enum class Status { PENDING, ACTIVE, SUSPENDED }

if (user.age >= ADULT_AGE && user.status == Status.ACTIVE) { allowPurchase() }

4. Functions (Chapter 3)

The book’s rules for functions are short and strict: small, do one thing, a descriptive name, few arguments, no side effects, and no flag arguments. A boolean flag is a sign the function does two things, one for each value.

Bad

fun processOrder(order: Order, sendEmail: Boolean) {
    if (order.items.isEmpty()) throw IllegalArgumentException("Empty order")
    var total = 0.0
    for (item in order.items) total += item.price * item.quantity
    order.total = total                       // hidden change to the input
    database.save(order)
    if (sendEmail) {
        mailer.send(order.customerEmail, "Thanks! You paid $total")
    }
}

This function validates, calculates, mutates its argument, saves, and sometimes emails. The flag sendEmail tells you it has two modes.

Clean

fun placeOrder(order: Order): PlacedOrder {
    requireNotEmpty(order)
    val placed = PlacedOrder(order, total = totalOf(order))
    orders.save(placed)
    return placed
}

fun sendReceipt(placed: PlacedOrder) {
    mailer.send(placed.customerEmail, "Thanks! You paid ${placed.total}")
}

private fun requireNotEmpty(order: Order) =
    require(order.items.isNotEmpty()) { "Empty order" }

private fun totalOf(order: Order): Double =
    order.items.sumOf { it.price * it.quantity }

Each function now fits in your head, has a name that says what it does, and the caller decides whether to send a receipt. The original order is not changed behind your back.

processOrdervalidate, sum, save,mutate, maybe email requireNotEmpty totalOf placeOrder sendReceipt Small, named steps that each do one thing
The goal is not tiny functions for their own sake, but names that tell the story.

5. Comments (Chapter 4)

The book’s attitude is that a comment is often an apology for code that is not clear enough. Before writing one, try to put the idea into a name or a small function. Comments that repeat the code, closing-brace markers, and commented-out code (which version control already remembers) are all noise. Good comments explain why, give a warning, or note a decision that the code cannot show.

Bad

// check if the employee is eligible for full benefits
if (employee.flags and HOURLY_FLAG != 0 && employee.age > 65) {
    i++   // increment i
}

// val oldRate = 0.08
// applyRate(oldRate)

Clean

if (employee.isEligibleForFullBenefits()) {
    benefitsGranted++
}

// The payment provider rejects more than 5 requests per second,
// so we pause between calls. Remove when we move to the batch API.
Thread.sleep(250)

The first comment disappeared because the function name says it. The last one stays because no name could explain a rule that lives in someone else’s system.

6. Formatting (Chapter 5)

Formatting is communication. The book’s advice is practical:

7. Objects, data and the Law of Demeter (Chapter 6)

Objects should hide their insides and expose behaviour. Data structures should expose their data and have little behaviour. The trouble starts with hybrids: classes with public fields and logic, where every caller edits the fields and the rules end up scattered.

Bad

class Account { var balance: Double = 0.0 }

// Somewhere else, and again somewhere else:
if (account.balance >= 50.0) {
    account.balance -= 50.0
}

Clean

class Account(balance: Double) {
    var balance: Double = balance
        private set

    fun withdraw(amount: Double) {
        require(amount > 0) { "Amount must be positive" }
        check(balance >= amount) { "Insufficient funds" }
        balance -= amount
    }
}

account.withdraw(50.0)

The rule “you cannot overdraw” now lives in one place. Callers tell the account what to do; they do not reach in and decide for it.

The Law of Demeter

A method should talk to its direct collaborators, not to the collaborators of its collaborators. A long chain of calls means your code knows too much about how other classes are built.

// Bad: the caller knows the whole structure
val city = order.customer.address.city.name

// Clean: ask the object that owns the knowledge
val city = order.shippingCity()

8. Error handling (Chapter 7)

Error handling should not bury the real logic. The book’s advice: use exceptions instead of return codes, give them enough context to be useful, define exception types around what the caller needs, and do not return or pass null to represent “something went wrong”.

Bad

// 0 = ok, 1 = insufficient funds, 2 = unknown account
fun withdraw(id: String, amount: Double): Int {
    val account = accounts[id] ?: return 2
    if (account.balance < amount) return 1
    account.balance -= amount
    return 0
}

when (withdraw("A-1", 50.0)) {      // every caller must remember the codes
    1 -> showMessage("Not enough money")
    2 -> showMessage("No such account")
}

Clean

class AccountNotFoundException(id: String) : Exception("No account with id $id")
class InsufficientFundsException(val needed: Double, val available: Double) :
    Exception("Needed $needed but only $available available")

fun withdraw(id: String, amount: Double) {
    val account = accounts[id] ?: throw AccountNotFoundException(id)
    account.withdraw(amount)        // throws InsufficientFundsException
}

try {
    withdraw("A-1", 50.0)
} catch (e: InsufficientFundsException) {
    showMessage("Short by ${e.needed - e.available}")
} catch (e: AccountNotFoundException) {
    showMessage("No such account")
}

Do not return null for “nothing special”: use a Special Case object

Kotlin makes null visible in the type (User?), which already helps a lot. Still, forcing every caller to check can be avoided when there is a sensible default behaviour:

// Bad: every caller must handle null
val discount: Discount? = customer.discount
val price = if (discount != null) discount.apply(total) else total

// Clean: a Special Case object with the same interface
object NoDiscount : Discount { override fun apply(total: Double) = total }
val discount: Discount = customer.discount ?: NoDiscount
val price = discount.apply(total)

Other points from the chapter: start by writing the try block (and its tests) so you decide what the operation guarantees, and avoid passing null into functions. In Kotlin, simply avoid nullable parameters unless null truly means something.

9. Boundaries (Chapter 8)

A boundary is where your code meets code you do not control: a library, an SDK, another team’s service. The advice is to keep that third-party code at arm’s length. Wrap it behind a small interface you own, so only one class knows the vendor’s details, and a vendor change touches one file.

Bad

// Vendor details leak into business code, in many places
fun checkout(order: Order) {
    val response = VendorClient(API_KEY).charges.create(
        mapOf("amount" to (order.total * 100).toInt(), "currency" to "usd")
    )
    if (response["status"] != "succeeded") error("Payment failed")
}

Clean

interface PaymentGateway { fun charge(amount: Money): PaymentResult }

class VendorPaymentGateway(private val client: VendorClient) : PaymentGateway {
    override fun charge(amount: Money): PaymentResult {
        val response = client.charges.create(
            mapOf("amount" to amount.inCents(), "currency" to amount.currency)
        )
        return if (response["status"] == "succeeded") PaymentResult.Paid else PaymentResult.Declined
    }
}

fun checkout(order: Order, payments: PaymentGateway) {
    check(payments.charge(order.total) == PaymentResult.Paid) { "Payment failed" }
}

Two more ideas in this chapter:

@Test
fun `the library parses ISO dates`() {
    assertEquals(LocalDate.of(2026, 10, 15), LocalDate.parse("2026-10-15"))
}

10. Unit tests (Chapter 9)

The book treats test code as first-class code. Tests should be F.I.R.S.T.: Fast, Independent, Repeatable, Self-validating, and Timely. A test should check one concept, and its name should read like a sentence about the behaviour.

Bad

@Test
fun test1() {
    val cart = Cart()
    cart.add(Item("pen", 10))
    assertEquals(10, cart.total())
    cart.add(Item("book", 20))
    assertEquals(30, cart.total())
    cart.clear()
    assertEquals(0, cart.total())
}

If this fails, which of three behaviours is broken? The name does not say.

Clean

@Test
fun `an empty cart has a total of zero`() {
    assertEquals(0, Cart().total())
}

@Test
fun `total is the sum of all item prices`() {
    val cart = Cart().apply { add(Item("pen", 10)); add(Item("book", 20)) }
    assertEquals(30, cart.total())
}

@Test
fun `clearing the cart resets the total`() {
    val cart = Cart().apply { add(Item("pen", 10)) }
    cart.clear()
    assertEquals(0, cart.total())
}

“One assert per test” is a guideline, not a law. What matters is one concept per test, so a failure points to one cause.

11. Classes (Chapter 10)

Functions should be small, and so should classes, but the book measures a class by responsibilities, not lines. A class should have one reason to change (the Single Responsibility Principle), and its methods should use its fields, which is called cohesion. When a few methods use a few fields and ignore the rest, there is a second class trying to get out.

Bad

class UserManager {
    fun logIn(name: String, password: String): Boolean { /* ... */ return true }
    fun monthlyReport(): String { /* builds a report */ return "" }
    fun sendWelcomeEmail(user: User) { /* talks to SMTP */ }
}

Clean

class Authenticator { fun logIn(name: String, password: String): Boolean { /* ... */ return true } }
class UserReport    { fun monthly(): String { /* ... */ return "" } }
class WelcomeMailer { fun send(user: User) { /* ... */ } }

I wrote about these two principles with exercises in the SOLID post.

12. Systems (Chapter 11)

Clean code at the scale of a whole application starts with one separation: building things is a different job from using them. If a class creates its own collaborators, it is welded to them. Create objects in one place at startup and hand them in.

Bad

class OrderService {
    private val repository = SqlOrderRepository()   // hard-wired, hard to test
}

Clean

class OrderService(private val repository: OrderRepository)

// One place builds the object graph (the "composition root")
fun main() {
    val service = OrderService(SqlOrderRepository())
    // ...
}

The same chapter covers a few related ideas:

13. Emergence (Chapter 12)

This short chapter passes on Kent Beck’s four rules of simple design. In priority order, a design is simple when it:

  1. Runs all the tests. A system that cannot be verified cannot be trusted.
  2. Contains no duplication.
  3. Expresses the intent of the programmer. Good names, small functions, and tests that read as examples.
  4. Minimises classes and methods. Do not split things so far that it becomes ceremony.

With tests in place, you can refactor freely to satisfy rules two to four. Removing duplication is often the first step:

// Before: the same idea twice
fun priceWithMemberDiscount(p: Double) = p - p * 0.10
fun priceWithStudentDiscount(p: Double) = p - p * 0.15

// After: the idea once, the difference as a parameter
fun priceAfterDiscount(price: Double, rate: Double) = price * (1 - rate)

14. Concurrency (Chapter 13)

Concurrency is hard because bugs depend on timing: they appear rarely and are hard to reproduce. The book lists defensive habits: keep concurrent code separate from other code, limit shared data, use copies of data where possible, keep threads as independent as you can, keep synchronised sections small, know your library’s tools, and test threaded code deliberately (run it many times, on different machines and loads).

Bad

var count = 0
repeat(1000) {
    thread { count++ }   // read, add, write: two threads can overwrite each other
}
// count is often less than 1000

Clean

val count = AtomicInteger()
coroutineScope {
    repeat(1000) {
        launch(Dispatchers.Default) { count.incrementAndGet() }
    }
}
// count is always 1000

The book’s examples use Java threads; in Kotlin, coroutines and structured concurrency give you cleaner tools for the same ideas. The common patterns it names are still worth knowing: producer-consumer, readers-writers and dining philosophers. The most reliable defence is to share as little mutable state as possible.

15. Successive refinement (Chapter 14)

This is the first of the three case studies. It follows the author as they build a small command-line argument parser, a class that reads flags such as -l -p 8080 -d /logs. The first version works and looks fine while it handles two kinds of argument. Then more kinds arrive (numbers, then text, then lists), and the code grows if chains and special cases until it becomes hard to touch. The chapter is mostly about what to do at that moment: stop adding features, and clean up first, in many tiny steps, running the tests after each one.

The lesson I took from it is that clean code is rarely written clean the first time. It is like writing an essay: the first draft makes it work, and the next drafts make it right. The mistake is shipping the first draft and moving on.

Before: every new type adds another branch

fun parseArg(schema: Char, value: String?): Any = when (schema) {
    'b' -> true
    'i' -> value?.toIntOrNull() ?: throw IllegalArgumentException("Expected a number")
    's' -> value ?: throw IllegalArgumentException("Expected text")
    else -> throw IllegalArgumentException("Unknown type $schema")
}
// A new type means editing this function, and every other function that switches on the letter

After: one small type per kind of argument

interface ArgumentMarshaler { fun parse(value: String?): Any }

object BooleanMarshaler : ArgumentMarshaler { override fun parse(value: String?) = true }
object IntMarshaler : ArgumentMarshaler {
    override fun parse(value: String?) =
        value?.toIntOrNull() ?: throw IllegalArgumentException("Expected a number")
}
object StringMarshaler : ArgumentMarshaler {
    override fun parse(value: String?) =
        value ?: throw IllegalArgumentException("Expected text")
}

val marshalers = mapOf('b' to BooleanMarshaler, 'i' to IntMarshaler, 's' to StringMarshaler)

The change is not done in one big jump. The book takes it a step at a time: introduce the new type beside the old code, move one kind across, run the tests, then the next. Adding a list type later means adding one object, not editing everything.

16. JUnit internals (Chapter 15)

The second case study takes a small class from the JUnit testing framework, the one that builds the “expected X but was Y” message and trims it to the part that differs. It was written by respected programmers, it has tests, and it is good code. The author then shows that it can still be improved, by applying the rules of the earlier chapters one by one: encapsulate conditions, rename things, remove awkward prefixes, extract small methods, and make hidden ordering between steps explicit.

The lesson is the Boy Scout rule in action. You do not need broken code to justify a small improvement, and you do not need a rewrite. You need tests to protect each small change.

Before: a negative condition and a vague name

class Compactor(private val fExpected: String?, private val fActual: String?) {
    fun compact(message: String): String {
        if (shouldNotCompact()) return format(message, fExpected, fActual)
        // ... build the shortened strings ...
        return format(message, shortExpected(), shortActual())
    }
    private fun shouldNotCompact() = fExpected == null || fActual == null || fExpected == fActual
}

After: a positive condition that names the case

class Compactor(private val expected: String?, private val actual: String?) {
    fun compact(message: String): String =
        if (canBeCompacted()) format(message, shortExpected(), shortActual())
        else format(message, expected, actual)

    private fun canBeCompacted() =
        expected != null && actual != null && expected != actual
}

Small changes: no f prefix on fields (the IDE already shows what is a field), one positive condition instead of a negated one, and the main path reads top to bottom.

17. Refactoring SerialDate (Chapter 16)

The third case study is a review of a date class from a widely used open-source library. The author does not begin by changing code. They begin with the tests: measure how much of the class they cover, add the missing ones, and only then start improving the design. After that come many findings, such as unclear names, unused code, comments that repeat the code, and numbers standing in for concepts, like 1 for January or 2 for Monday.

The lesson is the order of work: tests first, then refactor. A clean-up without a safety net is just a risky edit. The chapter also shows that even a heavily used library can hide design weaknesses, and that reviewing code is a skill worth practising.

Before: numbers that stand for months

fun daysInMonth(month: Int, isLeapYear: Boolean): Int = when (month) {
    2 -> if (isLeapYear) 29 else 28
    4, 6, 9, 11 -> 30
    else -> 31          // what if month is 13?
}

After: a type that makes invalid values impossible

enum class Month(private val days: Int) {
    JANUARY(31), FEBRUARY(28), MARCH(31), APRIL(30), MAY(31), JUNE(30),
    JULY(31), AUGUST(31), SEPTEMBER(30), OCTOBER(31), NOVEMBER(30), DECEMBER(31);

    fun length(isLeapYear: Boolean) =
        if (this == FEBRUARY && isLeapYear) 29 else days
}

val days = Month.FEBRUARY.length(isLeapYear = true)   // 29

The numeric version needs a comment and a default branch to be safe. The enum version cannot be called with month 13 at all. (In real Kotlin code you would normally use java.time.Month or YearMonth rather than writing your own.)

18. Smells and heuristics (Chapter 17)

The last chapter is a reference catalogue of more than sixty “smells”: signs that code may be wrong. It is grouped by area, and it is meant for looking things up during review rather than reading straight through. First, the six big symptoms of a rotting design:

SmellWhat it feels like
RigidityA small change forces changes in many other places.
FragilityYou change one thing and something unrelated breaks.
ImmobilityYou cannot reuse a piece without dragging half the system along.
Needless complexityMachinery for a future that never came.
Needless repetitionThe same logic copied in several places, so fixes must be repeated.
OpacityCode that is hard to understand, even for its author after a month.

Then the catalogue, with a few examples from each group:

GroupExamples of smells
CommentsOutdated comments, redundant comments, commented-out code.
EnvironmentA build or a test run that needs more than one step.
FunctionsToo many arguments, output arguments, flag arguments, dead functions.
GeneralDuplication, dead code, inconsistency, clutter, feature envy (a method more interested in another class’s data), hidden dependencies between steps, magic numbers, code at the wrong level of abstraction, artificial coupling.
NamesNon-descriptive names, names at the wrong level of abstraction, ambiguous names, names that hide side effects.
TestsInsufficient tests, skipped trivial tests, ignored tests, untested boundary conditions, slow tests.

The book also ships a Java-specific group (wildcard imports, constants versus enums, and so on), which I leave out here. The next section covers the heuristics from this chapter that I use most.

19. Conditionals and understandability (from Chapter 17)

Prefer polymorphism to long type checks

A when over a type code has to be edited every time a new type arrives, and it is repeated everywhere the type matters. Let each type carry its own behaviour. (This is the same idea as the Open/Closed principle in my SOLID post.)

// Bad
fun area(shape: Shape): Double = when (shape.type) {
    "circle"    -> Math.PI * shape.radius * shape.radius
    "rectangle" -> shape.width * shape.height
    else        -> throw IllegalArgumentException("Unknown shape")
}

// Clean
sealed interface Shape { fun area(): Double }

class Circle(private val radius: Double) : Shape {
    override fun area() = Math.PI * radius * radius
}
class Rectangle(private val width: Double, private val height: Double) : Shape {
    override fun area() = width * height
}

Kotlin adds a nuance: a when over a sealed type is checked by the compiler, so a missing case is a compile error. For a closed set of shapes that is also a clean design. Use polymorphism when behaviour belongs to the type, and a sealed when when you are adding operations rather than types.

Explain with variables, and avoid negative conditions

// Bad
if (!(order.items.isEmpty() || !customer.isActive)) { ship(order) }

// Clean
val hasItems = order.items.isNotEmpty()
val canOrder = customer.isActive
if (hasItems && canOrder) { ship(order) }

Other tips from this part of the book: be consistent (if you call it fetch in one place, do not call it retrieve in another), wrap boundary conditions in a named value, and prefer a small value type like Money or EmailAddress over a bare Double or String. Kotlin’s @JvmInline value class makes that cheap.

20. Where I use judgement

The book is influential and also debated, and it is worth reading with a critical eye. These are the places where I apply it with care:

21. Takeaways, checklist and references

Before I open a pull request, I run through this list:

References

TermIn plain words
Clean codeCode that is easy to read, understand and change safely.
Boy Scout ruleLeave the code cleaner than you found it.
Magic numberA bare value in code whose meaning is not obvious.
Flag argumentA boolean parameter that switches a function between two behaviours.
Side effectA hidden change a function makes beyond returning a value.
Law of DemeterTalk to your direct neighbours, not to their neighbours.
Code smellA surface sign that something deeper in the design may be wrong.
F.I.R.S.T.Fast, Independent, Repeatable, Self-validating, Timely: qualities of good tests.
← Back to all writing