MASTERING CLEAN CODE: BEST PRACTICES FOR SOFTWARE ENGINEERS
Overview
This post will highlight a list of guidelines I personally consider of utmost importance for any Software Engineer, based on the content of the book “Clean Code: A Handbook of Agile Software Craftsmanship” by Robert C. Martin.
In the next section, I will share what “Clean Code” means to me, followed by the list of best practices, which are organized into categories based on scope, starting from general rules and principles and extending to rules about writing functions, classes, comments, tests, and other.
What is “Clean Code”?
While reading the book, I came across a quote from Michael Feathers, the author of “Working Effectively with Legacy Code”:
“Clean code always looks like it was written by someone who cares. There is nothing obvious that you can do to make it better.”
I strongly resonate with this statement, and from now on, this is how I will define Clean Code.
In more technical terms, I consider that Clean Code has the following general characteristics:
- Has tests and runs them all
- Contains no duplication
- Expresses the intent and all the design ideas of the engineers
- Minimizes the number of entities such as classes, methods/functions, etc.
- Can be fully understood just by reading it
General Rules & Principles
How code should be
In terms of General Rules and Principles, code should be:
Clear/Readable
- Making code readable is as important as making it executable
- Understand the code or refactor it to make it so clean and expressive that it becomes obvious how it works
- Never keep unused/commented code
As Harold Abelson states in his book called “Structure and Interpretation of Computer Programs”:
“Programs must be written for people to read, and only incidentally for machines to execute.”
I think that all Software Engineers should spend time making their code beautiful and clear alongside making it correct and performant.
Maintainable
Good software designs accommodate change without huge investments and rework. This can be easily achieved by using the right tools, principles, and patterns for the job.
Consistent
Engineers should stick to the conventions that are in place if they’re good, or change them completely if they’re bad.
Rules to keep in mind when designing systems or writing code
- Projects should start as simple as possible and adapt along the way
- A piece of code should be where you expect to find it
- Concepts that are closely related should be kept close to each other
- Use standards wisely, when they add demonstrable value
- Refactor mercilessly - be proactive and always leave code better than you found it
Principles and Patterns that are usually useful
-
Last Responsible Moment
Postpone decisions to the last possible moment to make the most informed choices. A premature decision implies suboptimal knowledge.
-
Principle of Least Surprise
A function/class should implement the behaviors that another programmer could reasonably expect.
-
DRY (Don’t Repeat Yourself)
DRY encourages the use of reusable components, functions, and modules. By abstracting common patterns into reusable code, Software Engineers can avoid duplication and increase code reusability.
In addition to that, by adhering to DRY, code also becomes more maintainable and readable, since a piece of logic is in only one place and the changes to it need to be made in just that place. -
KISS (Keep It Simple, Stupid)
KISS is a design principle that emphasizes simplicity and avoiding unnecessary complexity in Software Development. The core idea is that systems and code should be as simple as possible while still meeting requirements, which makes them easier to understand, maintain, and debug.
-
YAGNI (You Aren’t Gonna Need It)
YAGNI is a principle in Software Development that recommends not adding functionality until it is necessary. Basically, it aims to avoid the pitfalls of over-engineering by only implementing features and components that are required at the current time, based on actual needs, rather than speculative future requirements.
-
Hide Your Data
Good Software Engineers learn to limit what they expose at the interfaces of their classes and modules.
The fewer methods a class has, the better. The fewer variables a function knows about, the better. The fewer instance variables a class has, the better.
Hide your data. Hide your utility functions. Hide your constants and your temporaries.
Concentrate on keeping interfaces very tight and very small.
By doing so, you ensure that the modules you write will be used appropriately in the future. -
Design Patterns
Knowledge of design patterns is really important because they offer a common language for Software Engineers to communicate with each other, while also providing a set of solutions to common problems, which can ensure that code is written in a consistent and high-quality manner.
However, overuse can also lead to low maintainability and readability. Therefore, use them with caution, while also taking KISS and YAGNI into account.
Classes
Guidelines for classes:
-
Classes should respect OOP and SOLID principles
-
Systems should have many, small, single-responsibility classes rather than few, large, multi-purpose ones
This implies the same logic, but an easier-to-understand and navigate system.
-
Low Coupling, High Cohesion
Coupling refers to the degree of interdependence between software modules and it can be lowered by keeping interfaces small (exposing as few properties/methods as possible).
Cohesion refers to the degree of relatedness between members of a class and it can be increased by using most of the class members in most methods.
By striving for low coupling and high cohesion, modules become more readable, maintainable, and reusable. -
Abstractions should be isolated by level
High-level details in high-level modules and low-level details in low-level modules.
-
Structure > Conventions
Forcing the implementation of abstract classes is better than having a convention over switch clauses.
-
Polymorphism > Conditional Logic
Choosing polymorphism over conditional logic results in better maintainability and readability, while also following SRP (Single Responsibility Principle) and OCP (Open-Closed Principle).
Functions/Methods
Functions should:
-
Be small (< 10 lines) and have a small number of arguments (< 3)
If a function cannot be small, it should, at least, return only once.
-
Do only one thing (SRP)
If another function, which is not just a restatement of the implementation, can be extracted from the original one, then it’s wrong.
-
Respect Command Query Separation (CQS)
Functions should be either queries or commands.
Query - returns data but has no side-effects
Command - changes the state (side-effects) but does not return data -
Be close to related functions
If one function calls another, they should be vertically close and the caller should be above the callee, if possible.
-
Separate object creation from usage
A function should receive an object that it needs as an argument instead of creating it internally. (Dependency Injection)
Reduces coupling and eases the testing process. -
Avoid temporal dependencies
They occur when the order in which functions are called affects the outcome because they rely on the current state of an object.
This means that calling the functions in any other order would result in an incorrect or unexpected outcome.
To overcome this, functions should receive their dependencies as arguments, rather than relying on the object’s state. (Dependency Injection) -
Have no output arguments (in OOP)
They’re confusing.
Modify the object through “this” instead.
Names
Tips for picking good names for modules (classes/functions):
-
Expressing Intent
- The name should describe the responsibility it fulfills, but not the implementation details
- It should tell you why it exists, what it does (including side-effects), and how it is used
- A long, descriptive name is better than a short, enigmatic one or a long, descriptive comment
- If coming up with a name is hard or if a comment is needed, the module has too many responsibilities (breaks SRP)
-
Conventions
- Use standard conventions when possible (design patterns names, is/has - for booleans, to - for type casting)
- Pick one word per concept and stick to it (get/retrieve/fetch)
-
Avoid Mental Mapping
- Readers shouldn’t have to mentally translate names into other names they already know.
-
Scope
- Names should not be encoded with scope or type information. But, the longer the scope, the longer and more precise the name should be.
Comments
Rules for comments:
-
Structure
- Brief, clear, and correct
-
Role
- Reserved for technical details about the code or design
- Say things that the code cannot say by itself
- Avoid comments if the information can be better held in a different place - module name, actual code, VCS, or Issue Tracker
-
Scope
- Any comment that forces you to look in another module for the meaning of that comment is bad
- Any comment that might become obsolete should not be written
Clauses
How to write clauses (conditionals/loops):
- If/else/while/for statements should be on a single line
- No nested if/else/while/for statements - use functions instead
- Use positive conditionals rather than negatives - easier to understand
- Long conditional statements (or switch cases) can be tolerated only if they appear once, are used to create polymorphic objects, and are hidden behind an inheritance relationship so that the rest of the system cannot see them - Abstract Factory
Error handling
How to handle errors:
- Separate exception-handling logic in separate functions
- The exception-handling functions should start with try and end with catch/finally (SRP)
- Returning error codes from command functions violates CQS => throw (ideally built-in) exceptions
- Each thrown exception should provide enough context to determine the source and location of an error
Testing
How to write tests:
-
FIRST
- Fast
- Independent (from each other)
- Repeatable (in any environment)
- Self-validating (boolean output - pass (true)/fail (false))
- Timely (written before production code) - TDD (Test-Driven Development)
-
Single concept per test
-
Minimal number of asserts
-
A test suite should test everything that could possibly break
- Aim for high test coverage
-
Test code should be as clean as production code
-
Avoid unneeded details
- A programmer should easily understand what the test does by reading it
-
Having a natural order of tests can show when is the program failing
-
Edge cases are really important
-
Test everything near bugs
- If you find a bug somewhere, it’s probably not the only one
-
Build-operate-check pattern for tests
- Build the test data
- Operate on the test data
- Check that the operation yielded the expected results
Concurrency
Dealing with concurrency:
- Concurrency code should be isolated from the business logic
- Get non-threaded code working first
- Threads should be as independent as possible from one another
- Keep synchronized sections small for better performance
- Avoid using more than a method on a shared object
- If you must, lock the object once and call all functions instead of successively locking and unlocking
End note
Remember to always take care of your code and leave it better than you found it!
