Principles Gathered from Clean Code: A Handbook of Agile Software Craftsmanship

It's worth mentioning (as is done in the book), that memorising these principles is not simply enough; these rules are simply heuristics that have worked for Robert C. Martin and others - you will need to use your judgement on where and how to use them.
I would recommend using this article as an overview of the topics covered and diving into the book itself to see the details where you feel you will benefit the most.

1. The cost of bad code and the essence of clean code

Martin starts the book by warning us of the cost of owning a mess. In short, systems that are a mess are expensive and painful to maintain.
Software systems can become so unmaintainable that the developers rebel out of frustration, calling for an overhaul of the legacy codebase. If managers relent, a new team is formed and a race to build the new system begins.
Productivity briefly spikes for the new team, but as the new codebase grows (with the same old dirty code habits), productivity quickly slows. It takes years for the “new” system to catch up to the old; by this time the original team that started on the new system have gone and everyone is already calling for another overhaul.
The cycle continues.
Martin goes on to ask many well-known programmers to define what "clean" code is. They converge on similar themes. The essence is this:
💡
“Clean” code is simple and conveys clearly its intent to the reader.
The practices Martin promotes for the rest of the book are simply ways of making the intent of your code clearer.
Martin advocates for the Boy Scout's Rule:
Leave the campground (codebase) cleaner than how you found it.

2. Meaningful names

Naming variables, functions, and classes sensibly goes a very long way to making your code more understandable to a reader. Obscure, uninformative, or misinformative names require the reader to become a detective, sleuthing through your code. Five minutes of refactoring can easily turn into thirty. Choosing a good name may take some thinking, but this is time well-invested in a cleaner codebase.
Choose names that:
  • Reveal the intent of the variable/ function/ class (can I guess what everything does without needing read the logic)
  • Avoid disinformation (don't call something accountList unless it really is a list!)
  • Avoid non-information (don't call a variable i unless indexing in a loop)
  • Make meaningful distinctions - every named "thing" should be something distinct, where the difference between these distinct things should be clear. For example, having car and carInfo is not a helpful distinction (do you really need two variables here if you can't distinctly name them?).
  • Are pronounceable - make life easy for yourself!
  • Are searchable - make it easy to grep search the entire codebase for the variable you're looking for (unique names trump common ones).
  • Are not encoded - don't use prefixes; if your variables are suitably named these shouldn't be required and will, at best, be ignored by readers.
  • Avoid “mental mapping” - do not require the reader to translate your name to something else in her head for them to make sense of it.
  • Follow a convention - use one name for one concept and stick to it.
  • Refer to the problem domain - developers must understand the business domain they are writing software for (rather than mindlessly typing away about things they know nothing about).
  • Refer to the solution domain - it will be programmers reading your code, so don't be afraid to use CS terms. I find referring to the problem domain preferable where possible.
  • Add meaningful context - add the context that will be required by the reader at the level of abstraction that the reader will be using the variable (the variable state makes sense next to addressLineOneaddressLineTwo but is extremely misleading used out of this context).
  • Don't add gratuitous context - if you prefix all variables in the Customer class with customer then every time you type c into your IDE, it will take nine keystrokes before your autocomplete starts helping you out - don't make your IDE work against you!
  • Use verbs for functions - verb/ noun pairs are very descriptive (write(name) shows that name is being written - even better would be writeField(name) as we have also described what name is in the function). Also, try to use the same verbs everywhere (don't mix fetch and get)
Don't underestimate the power that the choice of your names can have on the cleanliness of your code! This is something I often find myself considering when reviewing code.

3. Functions

Small!

Functions should hardly ever be longer than 20 lines - any longer and you need to break your logic down into smaller steps. This reduces the mental strain on the reader and makes it easier to spot any bugs in your own code. Breaking down your code into smaller functions means that you naturally begin to label (by creating a new function with a variable name) and sort the logic of your code into related steps.
Naturally, small functions also avoid excessive indentation, which increases readability.

Do one thing!

Functions should do one thing. They should do it well. They should do it only.
If you can extract another function from within your function, with a name which is not merely a restatement of the outer function's name, then you should do so. For example:
function login(username, password) { if areValidUserCredentials(username, password) return initialiseSession(username); throw Exception('Invalid user credentials'); } function areValidUserCredentials (username, password) { const user = this.getUserByUsername(username); return password === decrypt(user.passwordHash) }
is better than:
function login(username, password) { const user = this.getUserByUsername(username); if (password === decrypt(user.passwordHash)) return initialiseSession(username) throw Exception('Invalid user credentials'); }
Perhaps a trivial example, but here we see the first implementation splits the function into a non-trivial extra step of verifying the credentials. Understanding login in the first implementation is simple - irrelevant details are abstracted away into areValidUserCredentials.
If I want to understand the login function for the second implementation, I now also need to figure out what the instructions const user = this.getUserByUsername(username); and password === decrypt(user.passwordHash are actually doing, because they are mixed in with the other login logic. To understand a single function I need to understand the workings of two functions.
Aside: switch statements
On the topic of switch statements (which by their nature, perform N functions), Martin says they can be tolerated if buried in the basement of an abstract factory and are used to implement polymorphism.
The obvious problem arises: what is "one" thing? It's difficult to give a one-size-fits-all rule, but considering levels of abstraction can help us determine this...

Functions should SLAP! (Single Level of Abstraction Principle)

An interesting and important concept that Martin vocalised for me was ensuring that we only have one level of abstraction per function. Examples of different levels of abstraction would be:
renderHtml() // high-level const pageTitle = getPageSectionTitle() // mid-level shoppingListToRender.append(shoppingItem) // low-level
Keeping these levels of concepts separated cleans up your code and only shows the reader the level of detail they would expect to see in that function.
Martin describes the step-down rule to check if your abstractions are of the right level; you should be able to read a program as if it were a series of TO paragraphs. Here is an example in JavaScript:
function registerUser () { const contactDetails = collectUserContactDetails(); const paymentDetails = collectUserPaymentDetails(); const user = {...contactDetails, ...paymentDetails}; createNewUser(user); }
To register a user, we collect the user's contact details, then we collect the user's payment details, then we combine the two and create a new user from the result.
This reads like a simple set of instructions which is understandable to a non-technical reader. We can then move down one level of abstraction and do something similar for collectUserContactDetails to describe how this action is performed.
This way of organising concepts can be very difficult and requires practice. It comes hand-in-hand with making sure that your functions do only one thing.

Fewer arguments

The ideal number of arguments to pass to a function is zero, but you should never need to use more than three. Passing round arguments uses conceptual power and can expose a lot of the inner workings of a function.
In the case that we are tempted to use more than three arguments, notice that some of these variables can be grouped into an object or list of related values which express meaning as a single concept. If we then need access to a new piece of data from one function in another, we don’t need to change the function signatures of all the intermediate function calls - we just pack up the new piece of data in one place and unpack it in another.
💡
Don’t use boolean “flag” arguments which change the behaviour of your function - I look out for this one when reviewing code - it’s a sign that your function has too much responsibility!

Side effects

A function that contains side effects is one that creates a change outside of its own scope.
Depending on when you call it, the state in your upper scope can be affected, which is confusing and creates a temporal coupling in your code. Side effects are a way of lying to the reader about what your function is doing.
Consider:
function checkPassword (userId, passwordToCheck) { const user = getUserById(userId); if (user) { const encodedPassword = user.passwordHash; const userPassword = dcrypt(encodedPassword); if (userPassword === passwordToCheck) { initializeSession(); return true; }; } return false }
checkPassword will return the correct output, however, it also calls initializeSession if our password is correct (which changes some state outside the scope of the function). This code is just asking for bugs to arise.
Another way to give another developer a nasty surprise is to mutate a data structure passed into your function. They will have hours of fun trying to figure out why their code is doing something strange. Avoid mutating data - create a new object and return it!
Here is an example of how this can go wrong:
// (in the body of some outer function) ... console.log({ allIds }) // output: { allIds: [1, 2, 3] } if (!isIdRegistered(id, allIds)) { allIds.append(id) } console.log({ allIds }) // output: { allIds: [4] } - who stole all my ids!? ... function isIdRegistered(id, allIds) { for (let i=0; i<allIds.length; i++) { const registeredId = allIds.pop(); if (id === registeredId) return true; } return false; }
Our function, isIdRegistered, mutates the allIds list with the .pop method (which mutates the original data structure). The outer body logic reads as: check if the id is already registered, and if not, add it to the list of allIds. A reader would not expect isIdRegistered to alter allIds.
💡
Pure functions (without side effects) should be the only ones you use - don’t lie to your readers!

Command-query separation

Functions should either do something (perform some action on data) or answer something (retrieve data), not both; if it does, your function is doing more than one thing - split it out!

Extract try/catch blocks

Error handling is one thing - a function that handles errors should do nothing else. Try/except blocks are confusing and should ideally be extracted to their own functions so the intent of the function is clearer to the reader. For example:
function delete(page) { try { deletePageAndReferences(page); } catch (Exception e) { logError(e); } } function deletePageAndReferences(page) { deletePage(page); registry.deleteReference(page.name); configKeys.deleteKey(page.name.makeKey()); }
is clearer than:
function delete(page) { try { deletePage(page); registry.deleteReference(page.name); configKeys.deleteKey(page.name.makeKey()); } catch (Exception e) { console.error(e.message); throw e; } }
Error handling should be classified as one thing. Extracting the exception handling makes it easy to understand deletePageAndReferences and not be distracted by the error handling. This removes the unnecessary complexity for the reader.

DRY (don't repeat yourself)

It goes without saying, that creating reusable abstractions reduces duplication in your code. This means you only need to change your code in one place rather than many when the business logic changes.

Addendum: DRY (do repeat yourself)

It’s also important to note when not to use DRY! After learning about dry, junior developers often create abstractions everywhere in the code, which can be just as bad just repeating the code.
Consider abstractions to be like a see-saw: on one end we have code duplication (bad) and on the other, we have code coupling (very bad). Every time we create a new abstraction and use it in two places, those two places are coupled together.
If you are using the same function in many places, and want to change its behaviour for only some of the places you call it, changing the function may incur unintended side effects.
💡
Only apply DRY for things that change at the same time for the same reason, otherwise duplication is preferable.
Graph of developer priorities through their career (take a 15-year shortcut; start valuing readability above DRY now)
 
notion image

4. Comments

In general, don't use comments. If you need to add extra explanation to your code, it's a sign that you're not writing maintainable code. Even with the best of intentions, when the code changes (and it will), comments are often not updated by the person who changes the code, which clutters your codebase with redundant comments that the dev team are too nervous to remove.
It is easier to list the times a comment might be justified:
  1. Legal comments (e.g. copyright).
  1. Revealing intent behind a technical decision which would otherwise be obscure.
  1. Clarifying obscure methods of a standard (external) library that you can't refactor (you could also write a unit test for the external library to document it).
  1. Warnings to other programmers of the consequences of running some code.
  1. TODO comments - these should be immediately documented with a plan to address the tech debt (i.e. could be a link to a ticket where you plan to do the work - don’t create tickets solely to address tech debt, do it alongside a feature)
If you do leave a comment, make sure it is concise and serves a specific purpose.
Please, please, please, don't commit commented-out code!

5. Formatting

Formatting is important - it helps to make your code more readable.
Like smaller functions, smaller files are easier to understand - aim for fewer than 150 lines if possible.
Aim to have the highest level of abstraction closer to the top of the file to give the reader the broadest overview first. As you move down the page, the functions give more detail as the user requires - think of the order like a newspaper (the headline and summary come at the top with the details coming lower down the page).
Let a shared set of linting rules do the hard work for you. Your IDE should auto-format your code on save to keep consistency within the development team.

6. Objects and data structures

Martin describes data/object anti-symmetry: data structures should have no functions and expose their data, whereas objects should expose functions which operate on their data. These object methods hide the implementation details by creating an abstraction that programmers can interface with.
If you have simple data structures with functions that act on them and you need to change the structure of the data, all of your functions will need to change their implementation. On the other hand, it would be very easy to add a new function which acts on the data.
If you use objects which each expose methods to access data, it is very easy to add a new data type (just add a new class which implements the methods), however, it is difficult to change the behaviour of a method, because we need to change the implementation in many places (we need to change the behaviour wherever each class implements its method).
Consider whether it is likely you will be changing the behaviour or structure of the data.
Martin warns against creating hybrids, where classes have methods that act on the data, but also have public variables (or public accessors and mutators) which directly expose the innards, creating the worst of both worlds.

The law of Demeter

💡
A module should not know about the innards of the objects it manipulates.
This means that your code should not call methods on returned objects like so:
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
This creates a strong coupling between implementations across multiple modules and is known as train-wreck code due to the coupling (like train cars).
If ctxt was simply a data structure, then the law of Demeter would not apply (as data structures naturally expose their innards).
We should be telling ctxt to do something (it's not a data structure), not return something for another method to use. If we look at the outer context of how this outputDir is being used, we see that it is outputting some data to a file. We can tell ctxt to do this for us:
String outFile = outputDir + "/" + className.replace('.', '/') + ".class"; // notice mixed levels of abstraction! FileOutputStream fout = new FileOutputStream(outFile); BufferedOutputStream bos = new BufferedOutputStream(fout);
becomes
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);

Data transfer objects (DTO)

A DTO is a class with public variables and no methods. They are often useful when we interface with databases. Active records are a type of DTO which contains navigational methods such as find or navigate. Martin warns us not to be tempted to add business logic here; active records should be treated like data structures (which simply expose their data).

7. Error Handling (by Michael Feathers)

This section by Michael Feathers describes how we can handle errors and separate business logic from our error handling.

Prefer exceptions to returning error codes

Exceptions are designed to be thrown when something goes wrong. It is a way of your program giving you feedback to you about what went wrong - handling exceptions explicitly leads to more robust code. Additionally, you should stick to one convention on error handling throughout your project (otherwise you will be left with a mess).
If you return error codes, the caller must deal with the exception immediately:
if (deletePage(page) !== CODES.OK){ console.error('delete failed'); return CODES.ERROR; } if (registry.deleteReference(page.name) !== CODES.OK){ console.error('deleteReference from registry failed'); return CODES.ERROR; } if (configKeys.deleteKey(page.name.makeKey()) !== CODES.OK){ console.error('configKey not deleted'); return CODES.ERROR; } console.log('page deleted'); return CODES.OK;
as opposed to throwing using exceptions where the happy path code can be separated from error processing:
try { deletePage(page); registry.deleteReference(page.name); configKeys.deleteKey(page.name.makeKey()); } catch (Exception e) { console.error(e.message); throw e; }

Use unchecked exceptions

Languages like Java give programmers the ability to specify the exceptions a method can throw - the code will fail to compile if there are inconsistencies in exceptions. Feathers argues that the advantages are not worth the price of checked exceptions.
The price of checked exceptions is a violation of the Open/Closed Principle. If I declare a new type of exception three layers of abstraction down, I must propagate that declaration up to the top level of abstraction.
Many languages without checked exceptions have produced maintainable code - Feathers argues that this suggests they are not necessary.
*I’m not entirely sure if I agree with his reasoning here - I haven’t worked with a checked error system before, so I can’t speak from experience, so maybe it’s just not worth the effort for the benefits.
Error checking is a form of static code analysis (similar to having a static typing system), and experiencing the benefits that TypeScript provides over JavaScript in my daily life, I would be interested to try checked exceptions for myself.

Provide context with exceptions

Your error messages should provide enough context for you to quickly identify the source of the error (the operation and type of failure).

Define exception classes in terms of the caller's needs

When we define exception classes in an application, our most important concern should be how they are caught.
Feathers gives us an example of where exceptions are handled poorly:
ACMEPort port = new ACMEPort(12); try { port.open(); } catch (DeviceResponseException e) { reportPortError(e); logger.log("Device response exception", e); } catch (ATM1212UnlockedException e) { reportPortError(e); logger.log("Unlock exception", e); } catch (GMXError e) { reportPortError(e); logger.log("Device response exception"); } finally { ... }
In the context of calling port.open(), we only really care that there was a failure to open the port (not the specifics), which leads to duplication.
The proposed solution is to wrap the third-party (ACMEPort) API in our own class:
public class LocalPort { private ACMEPort innerPort; public LocalPort(int portNumber) { innerPort = new ACMEPort(portNumber); } public void open() { try { innerPort.open(); } catch (DeviceResponseException e) { throw new PortDeviceFailure(e); } catch (ATM1212UnlockedException e) { throw new PortDeviceFailure(e); } catch (GMXError e) { throw new PortDeviceFailure(e); } } } ... LocalPort port = new LocalPort(12); try { port.open(); } catch (PortDeviceFailure e) { reportError(e); logger.log(e.getMessage(), e); } finally { ... }
Wrapping third-party APIs is a best practice which allows you to work with an API you have defined and are comfortable with. Creating an abstraction also minimises the cost of swapping out the third-party library later.

Know when not to use try/catch

Consider the following code:
try { MealExpenses expenses = expenseReportDAO.getMeals(employee.getID()); m_total += expenses.getTotal(); } catch(MealExpensesNotFound e) { m_t += getMealPerDiem(); }
We are using a try/catch as part of the normal flow of a calculation. In this case, we can clean up our code by making expenseReportDAO return a MealExpenses object even in the special case where no meals are found:
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID()); m_total += expenses.getTotal(); ... public class PerDiemMealExpenses implements MealExpenses { public int getTotal() { // return the per diem default } }
Now the client doesn't need to deal with the exceptional behaviour as it is encapsulated in the special case object (this is called the special case pattern).

Avoiding null

It is a red flag if your code has/ requires null checks everywhere. Feathers gives us two rules for this:
  1. Don't return null from any of your functions.
  1. Don't pass null to any of your functions.
Consider throwing exceptions or returning a special case instead of returning null.

8. Boundaries (by James Grenning)

Using third-party code

There is a tension between interfaces of third-party code - providers strive for broad applicability, whereas users want interfaces specific to their use case.
Grenning gives the example of java.util.Map as a third-party interface. This module exposes many methods (such as clear) that we might not want to directly expose. He suggests that we wrap the third-party code which will restrict the interface to our specific use case. It also means that we only need to make changes in one place if the third-party interface changes.
Not every case of Map requires wrapping, but Grenning advises against passing Maps (or other interfaces at a boundary) around your system - keep these interfaces encapsulated.
Grenning proposes that when integrating third-party code, we should use learning tests to assert that the parts of the third-party code behave as expected so that when we come to debug our own code, we are certain that this is not a third-party error.
These learning tests help us to learn about the integration and also support us when we come to migrate to newer versions to ensure that our code remains compatible with the library.

Code that is yet to exist

Grenning describes a technique he used for where the API he wished to use had not yet been written. His team wrote an adapter, which encapsulated the interaction with the un-finished API, and whenever they ran into some functionality of the interface that they expected to exist, they wrote a method in the adapter for it. This meant that when the third-party API was finished, their system was ready, except for creating the interface to the third party.

Clean Boundaries

Grenning finishes this section by outlining that the boundaries between different systems are the points which often experience changes. We should isolate the interfaces between external systems to contain these maintenance points. We can wrap external code, or use an adapter to create a single point of interface. Our code should speak to the systems that we control.

9. Unit testing

It's not a surprise that Martin has positive things to say about test-driven development.

The three laws of TDD

  1. First Law You may not write production code until you have written a failing unit test.
  1. Second Law You may not write more of a unit test than is sufficient to fail, and not compiling is failing.
  1. Third Law You may not write more production code than is sufficient to pass the currently failing test.
Following these rules means we are locked into a cycle, where failing test code is written just before any addition to the production code is made.
How do we manage to keep this section of our codebase clean?

Keeping clean tests

Dirty tests are worse than no tests at all. They require maintenance, and the cost of maintaining dirty tests becomes very high as the codebase evolves. However, if you discard your automated tests, how can we be sure that any changes work as expected? Without automated tests, your production code will begin to rot.
Automated tests are the safety net that empowers you to make improvements to your code whilst reducing the fear that these changes are bugs.
Test code is just as important as production code.

Clean tests

Once again, readability is key to keeping good tests - the intent of the code should be clear! Aim for clarity, simplicity, and density of expression.
Tests should clearly follow the build-operate-check pattern; the first part builds test data, the second part operates on it, and the third asserts that we have our expected output. In other places, I’ve heard this as the AAA pattern (arrange, act, assert) - I think I like the alliteration better!
As in production code, we must refactor our tests into succinct and expressive forms.
Martin argues that we should form our own domain-specific testing language (specialised functions and utilities) which evolves as we develop our codebase.

One assert per test

Having a single assertion per test allows the reader to quickly understand the single intent of the test. If there are multiple assertions, it suggests that a test is doing more than one thing and can be split into two tests (think of the parallel to functions only doing one thing).
Martin proposes that these tests form a given-when-then convention, which he uses to name utilities in his testing language. This follows the build-operate-check pattern and helps simplify our tests (anything other than a given-when-then can be extracted).
When we need to separate out tests to have a single assertion, we can put the given-when code in a Before method and only have the assertion in each test.

Single concept per test

Don't force the reader to derive what each part of each test is doing - split your tests out if they cover different concepts.
You can write Given ... statements to figure out if tests cover the same concept. If the same test covers multiple premises, then they should be split out.

FIRST

Tests should be:
  1. Fast - when they are slow you will be tempted to run them infrequently.
  1. Independent - they should be able to run independently and in any order to prevent cascading issues.
  1. Repeatable - in any environment (in CI or on your laptop). They should be deterministic, or you will rationalise why they are failing.
  1. Self-validating - they should have a boolean output and check their success automatically.
  1. Timely - tests should be written in a timely manner (before you write your production code).

Conclusion

As software engineers, it’s our job to minimise the resources required to build and maintain(!) the desired behaviour of the system. To do this well, we must write clean code.
The essence of Clean Code is in the clear intent to the reader. That is to say, your code shouldn't be encoded!