Mastering the Craft: 10 Key Highlights from the “Clean Code” Bible for Every Developer

Learn the Art of Clean Coding with Javascript

Matthew Wong
Level Up Coding

--

Every programmer, regardless of their level of experience, can benefit from the wisdom in Robert C. Martin’s book, “Clean Code.” It has been curated as one of the must-read book for every software engineer. As a software developer, crafting clean, efficient, and maintainable code should be your top priority. In this article, we’ll explore the key highlights and principles outlined in “Clean Code,” helping you become a better developer and transforming your programming journey for the better.

Clean Code: A Handbook of Agile Software Craftsmanship by Robert C. Martin

There are 10 key areas in total that I think everyone should beware of to elevate the coding skills to the next level. They are Meaningful Names, Functions, Function Arguments, Comments, Formatting, Objects and Data Structures, Error Handling, Unit Tests, Classes and Code Smells.

1. Meaningful Names

TL;DR Choose clear, expressive names for variables, functions, and classes to improve readability and maintainability.

Using meaningful names for variables, functions, and classes in your code is crucial. This makes the code more readable, maintainable, and understandable for both the original developer and others who may work with the code in the future.

Here’s an example to illustrate the difference between poor and meaningful naming in JavaScript:

Poor Naming:

let d; // time in days
const r = 5; // interest rate
const p = 1000; // principal amount

function i(p, r, d) {
return (p * r * d) / 36500;
}

const interest = i(p, r, d);

Meaningful Naming:

const principalAmount = 1000;
const annualInterestRate = 5;
const investmentPeriodInDays = 30;

function calculateInterest(principal, rate, days) {
return (principal * rate * days) / 36500;
}

const interest = calculateInterest(principalAmount, annualInterestRate, investmentPeriodInDays);

In the meaningful naming example, we can see:

  1. Variables have descriptive names, such as principalAmount, annualInterestRate, and investmentPeriodInDays. These names make it clear what values they represent.
  2. The function has a meaningful name, calculateInterest, which clearly indicates what it does.
  3. The arguments for the function also have clear names, like principal, rate, and days, making it easy to understand what inputs the function requires.

Using meaningful names greatly improves the readability of the code, making it easier for others (and yourself) to understand and maintain it.

2. Functions

TL;DR Functions should be small, focused, and have a single responsibility. They should do one thing and do it well.

Having small, focused functions that adhere to the Single Responsibility Principle (SRP) is another highlight of the book. A function should do one thing and do it well, which makes the code more readable, maintainable, and testable.

Here’s an example to illustrate the difference between a function that doesn’t follow SRP and one that does in JavaScript:

Without Single Responsibility Principle:

function processEmployeeData(employeeData) {
const fullName = `${employeeData.firstName} ${employeeData.lastName}`;

// Calculate age based on birthdate
const today = new Date();
const birthDate = new Date(employeeData.birthDate);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDifference = today.getMonth() - birthDate.getMonth();
if (monthDifference < 0 || (monthDifference === 0 && today.getDate() < birthDate.getDate())) {
age--;
}

console.log(`Full name: ${fullName}`);
console.log(`Age: ${age}`);
}

const employeeData = {
firstName: 'John',
lastName: 'Doe',
birthDate: '1990-06-15',
};

processEmployeeData(employeeData);

With Single Responsibility Principle:

function getFullName(employeeData) {
return `${employeeData.firstName} ${employeeData.lastName}`;
}

function calculateAge(birthDate) {
const today = new Date();
const parsedBirthDate = new Date(birthDate);
let age = today.getFullYear() - parsedBirthDate.getFullYear();
const monthDifference = today.getMonth() - parsedBirthDate.getMonth();
if (monthDifference < 0 || (monthDifference === 0 && today.getDate() < parsedBirthDate.getDate())) {
age--;
}
return age;
}

function displayEmployeeInformation(employeeData) {
const fullName = getFullName(employeeData);
const age = calculateAge(employeeData.birthDate);

console.log(`Full name: ${fullName}`);
console.log(`Age: ${age}`);
}

const employeeData = {
firstName: 'John',
lastName: 'Doe',
birthDate: '1990-06-15',
};

displayEmployeeInformation(employeeData);

In the Single Responsibility Principle example, we can see that:

  1. The original processEmployeeData function is split into three smaller, focused functions: getFullName, calculateAge, and displayEmployeeInformation.
  2. Each function performs a single task: getFullName returns the full name, calculateAge returns the age, and displayEmployeeInformation displays the information to the console.
  3. The functions are more readable, maintainable, and testable since they are small and focused.

By adhering to the Single Responsibility Principle, we make the code easier to understand, maintain, and test, which leads to higher quality software.

3. Function Arguments

TL;DR Keep the number of arguments to a minimum and avoid output arguments when possible. This enhances the readability and understandability of the code.

Minimising the number of function arguments and avoiding output arguments are important. They help improving code readability and understandability.

Having fewer arguments makes it easier to understand the purpose of a function and how it should be used. Additionally, output arguments, which modify the input variables passed by reference, can lead to confusion and unintended side effects.

Multiple Arguments:

function calculateTotal(price, taxRate, discountRate) {
const taxAmount = price * taxRate;
const discountAmount = price * discountRate;
return price + taxAmount - discountAmount;
}

const price = 100;
const taxRate = 0.1; // 10%
const discountRate = 0.05; // 5%
const total = calculateTotal(price, taxRate, discountRate);

Fewer Arguments (Using Object):

function calculateTotal({ price, taxRate, discountRate }) {
const taxAmount = price * taxRate;
const discountAmount = price * discountRate;
return price + taxAmount - discountAmount;
}

const product = {
price: 100,
taxRate: 0.1, // 10%
discountRate: 0.05, // 5%
};
const total = calculateTotal(product);

In the Fewer Arguments example, we can see that:

  1. The function takes a single argument, an object containing the relevant properties (price, taxRate, and discountRate), instead of three separate arguments.
  2. By passing an object, we make the function more flexible, allowing for additional properties to be added in the future without changing the function signature.
  3. The code becomes more readable as the properties are grouped together logically within the object.

Next, let’s consider an example demonstrating the avoidance of output arguments:

With Output Argument:

function calculateAreaAndPerimeter(width, height, result) {
result.area = width * height;
result.perimeter = 2 * (width + height);
}

const dimensions = { width: 10, height: 5 };
const result = {};
calculateAreaAndPerimeter(dimensions.width, dimensions.height, result);

Without Output Argument:

function calculateAreaAndPerimeter(width, height) {
const area = width * height;
const perimeter = 2 * (width + height);
return { area, perimeter };
}

const dimensions = { width: 10, height: 5 };
const result = calculateAreaAndPerimeter(dimensions.width, dimensions.height);

In the second example, we can see that:

  1. The function returns an object containing the area and perimeter properties, instead of modifying an input argument.
  2. The function is easier to understand and use because it avoids side effects and follows a more common pattern of returning a result.

By minimising the number of function arguments and avoiding output arguments, we make our code more readable, understandable, and maintainable.

4. Comments

TL;DR Comments should be concise and meaningful. They should be used to explain the purpose of code and to clarify complex logic, but should not be used as a substitute for writing clear code.

Comments should be used appropriately in your code. Comments should be concise, meaningful, and used to explain the purpose or intent of the code, or to clarify complex logic. However, comments should not be used as a substitute for writing clear and readable code.

Poor Comments:

// Calculate total
function calcTot(a, b, c) {
// Add a and b
const t1 = a + b;
// Subtract c
const t2 = t1 - c;
// Return result
return t2;
}

In this example, comments are used excessively, and the code has poor naming conventions. The comments describe what the code does rather than why it does it. Now let’s refactor the code with better naming and more effective comments:

Effective Comments:

function calculateTotal(price, tax, discount) {
const totalPriceBeforeDiscount = price + tax;

// Apply the discount to the total price before discount
const finalPrice = totalPriceBeforeDiscount - discount;

return finalPrice;
}

In the improved example, we can see that:

  1. Variables and the function have descriptive names, which makes the code self-explanatory, reducing the need for comments.
  2. Comments are used sparingly, only to clarify the purpose of applying the discount.
  3. The code is easier to read and understand because the meaningful names and effective comments provide context.

Keep in mind that comments can become outdated or incorrect as code evolves, so it’s essential to keep them updated and relevant. When writing comments, focus on explaining the “why” behind the code or providing context for complex logic that might not be apparent from the code itself.

5. Formatting

TL;DR Consistent formatting, including indentation and spacing, improves code readability and makes it easier for others to understand and maintain.

Proper formatting improves code readability and makes it easier for others (and yourself) to understand and maintain the code.

Poorly Formatted Code:

function calculateTotal(price,tax,discount){const totalPriceBeforeDiscount=price+tax;const finalPrice=totalPriceBeforeDiscount-discount;return finalPrice;}
const productPrice=100;const taxAmount=15;const discountAmount=10;const total=calculateTotal(productPrice,taxAmount,discountAmount);console.log("Total:",total);

Well-Formatted Code:

function calculateTotal(price, tax, discount) {
const totalPriceBeforeDiscount = price + tax;
const finalPrice = totalPriceBeforeDiscount - discount;
return finalPrice;
}

const productPrice = 100;
const taxAmount = 15;
const discountAmount = 10;

const total = calculateTotal(productPrice, taxAmount, discountAmount);
console.log("Total:", total);

In the well-formatted example, we can see:

  1. Consistent indentation: Each level of code is indented with an appropriate number of spaces or tabs, making it easier to see the structure of the code.
  2. Spacing around operators: There is space around operators such as =, +, -, making the code easier to read.
  3. Line breaks: Line breaks are used to separate variable declarations, function calls, and other statements, which improves readability.
  4. Consistent use of quotes: The same type of quotes (double or single) should be used consistently throughout the code.
  5. Clear separation between code blocks: Blank lines are used to separate function declarations, variable declarations, and other logical sections of the code.

To maintain consistent formatting throughout your codebase, consider using tools like ESLint and Prettier to automatically format your code and enforce a consistent style.

6. Objects and Data Structures

TL;DR Encapsulation should be employed to hide implementation details and expose only necessary information, making it easier to reason about and change the code.

Encapsulation makes it easier to reason about and change the code since it isolates the impact of changes to a specific component or module.

Without Encapsulation:

const account = {
balance: 1000,
deposit: function (amount) {
this.balance += amount;
},
withdraw: function (amount) {
if (amount <= this.balance) {
this.balance -= amount;
} else {
console.log('Insufficient balance');
}
},
};

account.balance += 500; // Directly modifying the balance
account.withdraw(200);

With Encapsulation:

const createBankAccount = (initialBalance) => {
let balance = initialBalance;
const deposit = (amount) => {
balance += amount;
};
const withdraw = (amount) => {
if (amount <= balance) {
balance -= amount;
} else {
console.log('Insufficient balance');
}
};
const getBalance = () => balance;
return { deposit, withdraw, getBalance };
};

const account = createBankAccount(1000);

account.deposit(500); // Using provided methods to interact with the balance
account.withdraw(200);

const currentBalance = account.getBalance();

In the encapsulated example, we can see that:

  1. The balance variable is not directly exposed; it is hidden within the closure created by the createBankAccount function.
  2. Public methods are provided to interact with the account (deposit, withdraw, getBalance), which control access and manipulation of the balance.
  3. Users of the account object are limited to the provided methods and cannot directly modify the balance, ensuring the integrity of the object's state.

By utilizing encapsulation, we create a clear separation between an object’s internal state and the external interface used to interact with that object. This makes the code easier to understand, maintain, and modify, as changes to the internal implementation will have a limited impact on the code that uses the object.

7. Error Handling

TL;DR Error handling should be treated as a separate concern and should not be mixed with the main logic of the code. Using exceptions instead of error codes is recommended.

Proper error handling involves anticipating potential issues and handling them gracefully, making it easier to understand, diagnose, and fix problems when they occur. Using exceptions and appropriate error messages can help with this process.

Without Error Handling:

function divide(a, b) {
return a / b;
}
const result = divide(10, 0);
console.log('Result:', result); // Output: 'Result: Infinity'

In the above example, there is no error handling for division by zero, leading to an unexpected output of “Infinity.” Now let’s add error handling to handle this case:

With Error Handling:

function divide(a, b) {
if (b === 0) {
throw new Error('Division by zero is not allowed.');
}
return a / b;
}

try {
const result = divide(10, 0);
console.log('Result:', result);
} catch (error) {
console.error('An error occurred:', error.message);
// Output: 'An error occurred: Division by zero is not allowed.'
}

In the improved example, we can see that:

  1. The divide() function checks for division by zero and throws an Error with a meaningful message if it occurs.
  2. The calling code uses a try/catch block to handle the exception, which allows for graceful handling of the error and prevents the application from crashing.
  3. The error message is helpful and indicates the specific issue that occurred, making it easier to diagnose and fix.

By implementing proper error handling in your code, it’s easier to understand, diagnose, and fix problems when they occur, and helps to ensure a better user experience when issues arise.

8. Unit Tests

TL;DR Writing and maintaining a comprehensive suite of unit tests is essential for ensuring code quality and reducing the risk of bugs being introduced.

Three Laws of TTD

  1. You may not write production code until you have written a failing unit test.
  2. You may not write more of a unit test than is sufficient to fail, and not compiling is failing.
  3. You may not write more production code than is sufficient to pass the currently failing test.

Let’s write tests for a simple Stack class:

const Stack = require('./stack');

test('push() should add an item to the stack', () => {
const stack = new Stack();
stack.push(1);
expect(stack.peek()).toBe(1);
});

test('pop() should remove the top item from the stack', () => {
const stack = new Stack();
stack.push(1);
stack.push(2);
expect(stack.pop()).toBe(2);
});

test('peek() should return the top item without removing it', () => {
const stack = new Stack();
stack.push(1);
stack.push(2);
expect(stack.peek()).toBe(2);
expect(stack.peek()).toBe(2); // Repeating the call to ensure the item was not removed.
});

test('isEmpty() should return true if the stack is empty', () => {
const stack = new Stack();
expect(stack.isEmpty()).toBe(true);
stack.push(1);
expect(stack.isEmpty()).toBe(false);
});

// Law 1: Write a failing test before writing production code.
// Law 2: Write just enough of a test to fail.
test('pop() should throw an error if the stack is empty', () => {
const stack = new Stack();
expect(() => stack.pop()).toThrow(Error);
});

test('peek() should throw an error if the stack is empty', () => {
const stack = new Stack();
expect(() => stack.peek()).toThrow(Error);
});

// Law 3: Write just enough production code to pass the test.

By adhering to the Three Laws of TDD, we incrementally build the functionality of the Stack class, making sure that each step is guided by a failing test before implementing the corresponding production code. This process ensures that our code has good test coverage and is designed with testability in mind.

Keeping tests clean:

  1. Make tests readable by using clear and descriptive test names, as shown in the examples above.
  2. Keep tests focused on a single aspect of the code being tested.
  3. Ensure tests are independent and can be run in any order. In the examples above, each test creates its instance of Stack and does not rely on the outcome of other tests.

Clean tests provide several benefits:

  1. They serve as living documentation for your code, helping developers understand how the code is supposed to behave.
  2. They help you catch regressions and bugs early in the development process, saving time and effort.
  3. They make it easier to refactor and improve your code with confidence since the tests ensure that existing functionality still works as expected.

By treating your tests as an essential part of your codebase, you’ll be able to build higher-quality software with fewer bugs and a more stable foundation for future development.

Classes

TL;DR Classes should be small and focused, adhering to the Single Responsibility Principle. This makes them easier to understand, maintain, and extend.

Clean Code principles mention encapsulation, small classes, and the Single Responsibility Principle (SRP) when working with classes.

Let’s say we have an e-commerce application where users can place orders. We’ll create a simple Product class and an Order class that follows the mentioned principles.

product.js:

class Product {
constructor(name, price) {
this.name = name;
this.price = price;
}

getName() {
return this.name;
}

getPrice() {
return this.price;
}
}

module.exports = Product;

In the Product class, we follow the SRP and Encapsulation principles by providing getter methods for the name and price properties. The class is small, focused, and has a single responsibility: to represent a product.

order.js:

class Order {
constructor() {
this.items = [];
}

addItem(product, quantity) {
this.items.push({ product, quantity });
}

getTotal() {
return this.items.reduce(
(total, item) => total + item.product.getPrice() * item.quantity,
0
);
}
}

module.exports = Order;

In the Order class, we also adhere to the SRP and Encapsulation principles. The class is responsible for managing the order items and calculating the total price. We avoid exposing the internal structure of the items array by providing specific methods for adding items and getting the total.

Now let’s use the Product and Order classes in another JavaScript file:

index.js:

const Product = require('./product');
const Order = require('./order');

const product1 = new Product('Laptop', 1000);
const product2 = new Product('Mouse', 50);

const order = new Order();
order.addItem(product1, 1);
order.addItem(product2, 2);

console.log('Order Total:', order.getTotal()); // Output: 'Order Total: 1100'

In this example, we can see that:

  1. The Product and Order classes follow the SRP, each having a specific responsibility.
  2. Both classes are small, with a limited number of methods and properties, which makes them easier to understand and maintain.
  3. Encapsulation is applied, hiding the implementation details and exposing only the necessary information through public methods.

By adhering to the Clean Code principles when working with classes, we create code that is more maintainable, easier to understand, and promotes better code organisation.

10. Code Smells

TL;DR Be aware of and address common “code smells” — indicators that the code may have design or structural issues that need to be addressed.

Code smells are patterns or symptoms in the code that indicate there may be design or structural issues. They don’t always directly cause bugs but can lead to a codebase that’s hard to maintain, extend, and understand. Here are some common code smells and their examples in JavaScript:

1. Long Functions

Functions that are too long and try to do too much can be hard to understand and maintain.

function processData(data) {
// Step 1: validation
if (data === null || typeof data !== 'object') {
return null;
}

// Step 2: normalization
data = data.trim().toLowerCase();

// Step 3: processing
const words = data.split(' ');
const wordCounts = {};
for (const word of words) {
if (wordCounts[word]) {
wordCounts[word]++;
} else {
wordCounts[word] = 1;
}
}

// Step 4: output
console.log('Word counts:', wordCounts);
}

A better approach would be to split this function into smaller, focused functions:

function validateData(data) {
if (data === null || typeof data !== 'object') {
return null;
}
return data;
}

function normalizeData(data) {
return data.trim().toLowerCase();
}

function countWords(data) {
const words = data.split(' ');
const wordCounts = {};
for (const word of words) {
if (wordCounts[word]) {
wordCounts[word]++;
} else {
wordCounts[word] = 1;
}
}
return wordCounts;
}

function outputResult(wordCounts) {
console.log('Word counts:', wordCounts);
}

function processData(data) {
data = validateData(data);
if (!data) return;
data = normalizeData(data);
const wordCounts = countWords(data);
outputResult(wordCounts);
}

2. Large Classes

Classes that are too large and have too many responsibilities can be hard to understand and maintain.

class UserManager {
constructor(users) {
this.users = users;
}

findUser(id) {
return this.users.find(user => user.id === id);
}

getUserEmail(id) {
const user = this.findUser(id);
return user ? user.email : null;
}

// ... many other user-related methods

log(message) {
console.log(message);
}

// ... many other logging methods
}

In this case, we should split the UserManager class into two separate classes, each with a single responsibility:

class UserManager {
constructor(users) {
this.users = users;
}

findUser(id) {
return this.users.find(user => user.id === id);
}

getUserEmail(id) {
const user = this.findUser(id);
return user ? user.email : null;
}

// ... other user-related methods
}

class Logger {
log(message) {
console.log(message);
}
// ... other logging methods
}

3. Duplicate Code

Duplicate code makes it hard to maintain and introduce changes since the same logic may need to be updated in multiple places.

function processUser(user) {
// ... some processing
console.log(`Processed user: ${user.name}`);
}

function processProduct(product) {
// ... some processing
console.log(`Processed product: ${product.name}`);
}

In this case, we should extract the common code into a separate function:

function logProcessingResult(type, name) {
console.log(`Processed ${type}: ${name}`);
}

function processUser(user) {
// ... some processing
logProcessingResult('user', user.name);
}
function processProduct(product) {
// ... some processing
logProcessingResult('product', product.name);
}

4. Magic Numbers or Strings

Using unexplained numbers or strings in the code can make it hard to understand the purpose or meaning of those values.

function calculateTotal(price, quantity) {
return price * quantity * 1.07; // What does 1.07 mean?
}

A better approach would be to use a constant to represent the value with a meaningful name:

const TAX_RATE = 1.07;

function calculateTotal(price, quantity) {
return price * quantity * TAX_RATE;
}

5. Deep Nesting

Deeply nested code blocks can be hard to understand and follow.

function processOrder(order) {
if (order) {
if (order.items) {
if (order.items.length > 0) {
// ... process the order
} else {
console.log('No items in the order.');
}
} else {
console.log('Order is missing items.');
}
} else {
console.log('No order to process.');
}
}

We can refactor the code to reduce nesting by using early returns:

function processOrder(order) {
if (!order) {
console.log('No order to process.');
return;
}

if (!order.items) {
console.log('Order is missing items.');
return;
}
if (order.items.length === 0) {
console.log('No items in the order.');
return;
}

// ... process the order
}

By identifying and addressing code smells, you can improve the overall quality of your codebase, making it easier to understand, maintain, and extend.

To know more about code refactoring, check out this refactoring guide:

By following the key highlights and principles from “Clean Code” by Robert C. Martin, you’ll be well on your way to crafting elegant, efficient, and maintainable code. Remember that clean code is an ongoing journey, and it takes consistent effort to master the craft. Embrace these practices and watch as your code becomes clearer, more manageable, and more enjoyable to work with.

To know more about my backend learning path, check out my journey here:

Level Up Coding

Thanks for being a part of our community! Before you go:

🚀👉 Join the Level Up talent collective and find an amazing job

--

--