10 Best Practices for Writing Clean and Maintainable Code

Mattias Tornqvist
15 min readApr 4, 2023

--

Programming is both an art and a science, and like any other craft, it involves mastering a set of techniques and practices that can help you build better, more reliable, and maintainable software. In this article, we’ll explore ten best practices that every developer should know and apply to their daily work. From naming conventions and code organization to testing and dependency injection, these practices are not only essential but can also make your code easier to read, debug, and modify. So whether you’re a seasoned pro or just getting started, read on and discover how you can level up your coding skills!

#1: But what should we name it?!

The first best practice for writing clean code is to follow a consistent naming convention. Naming conventions should be meaningful, descriptive, and consistent throughout the entire codebase. This makes it easier for developers to understand what the code does and how it fits together.

Practical examples of following a consistent naming convention include:

  1. Use descriptive names for variables, functions, and classes: For example, instead of naming a variable “x”, name it something more descriptive like “numberOfItems”. This makes it clear what the variable represents and makes the code more readable.
  2. Use camelCase or snake_case consistently: Choose a naming convention for variables and stick to it throughout the codebase. For example, if you decide to use camelCase, then use it consistently for all variable names.
  3. Be consistent with abbreviations: If you use an abbreviation for a variable or function name, make sure that it is consistent throughout the codebase. For example, if you abbreviate “number” as “num” in one variable name, then use “num” consistently for all variable names that use this abbreviation.
  4. Use clear and meaningful function names: Functions should have names that accurately describe what they do. For example, a function that calculates the total price of items in a shopping cart should be named something like “calculateTotalPrice”, rather than “function1”.
  5. Use consistent formatting for naming conventions: Consistency is key when it comes to naming conventions. Choose a formatting style for your codebase and stick to it throughout. For example, if you choose to capitalize the first letter of every word in a variable name, then do this consistently for all variable names.
  6. Avoid single letter variable names: Using single letter variable names, such as “i” or “j”, can make it difficult for other developers to understand the purpose of the variable. Instead, use more descriptive names that provide context and clarity.
  7. Consider the context of the code: The naming convention should be appropriate for the context of the code. For example, a variable name in a math function should be named appropriately to its use in math. Conversely, a variable name in a function that calculates the price of a product may be named based on the context of commerce.

Example 1 — Inconsistent Naming Convention:

function calculate(a, b) {
let c = a + b;
return c;
}

In this example, the naming convention is inconsistent. The function name is descriptive, but the variable names are not. a and b are not very descriptive, and c could be named something more meaningful, such as sum. This makes the code harder to read and understand.

Example 2 — Descriptive Naming Convention:

function calculateSum(firstNumber, secondNumber) {
let sum = firstNumber + secondNumber;
return sum;
}

In this example, the naming convention is consistent and descriptive. The function name accurately describes what the function does, and the variable names provide clear context and meaning. This makes the code easier to read and understand.

Example 3 — Using Abbreviations Consistently:

function calculateTax(subtotal, taxRate) {
let tax = subtotal * taxRate;
return tax;
}

In this example, the abbreviation “tax” is used consistently throughout the function, making it clear what the variable represents. This improves the readability of the code and makes it easier for other developers to understand what the function does.

#2: Functions should be small and even smaller!

The second best practice for writing clean code is to write small and focused functions. Functions should have a clear and specific purpose, and should do one thing and do it well. This makes the code easier to read, test, and maintain.

Practical examples of writing small and focused functions include:

Breaking down complex functions into smaller ones: If a function is too long or complex, it can be difficult to understand and maintain. By breaking it down into smaller functions, each with a specific purpose, the code becomes easier to understand and modify.

// Complex function
function calculateTotalPrice(items) {
let totalPrice = 0;
for (let i = 0; i < items.length; i++) {
totalPrice += items[i].price;
}
let tax = totalPrice * 0.1;
let finalPrice = totalPrice + tax;
return finalPrice;
}

// Refactored function
function calculateTotalPrice(items) {
let totalPrice = calculateSubtotal(items);
let tax = calculateTax(totalPrice);
let finalPrice = totalPrice + tax;
return finalPrice;
}

function calculateSubtotal(items) {
let totalPrice = 0;
for (let i = 0; i < items.length; i++) {
totalPrice += items[i].price;
}
return totalPrice;
}

function calculateTax(subtotal) {
return subtotal * 0.1;
}

Avoiding “side effects” in functions: A function should only do what it is intended to do and should not have any unintended side effects on other parts of the code. This makes the code easier to test and debug.

// Function with side effects
let totalPrice = 0;
function calculateTotalPrice(items) {
for (let i = 0; i < items.length; i++) {
totalPrice += items[i].price;
}
}

// Refactored function without side effects
function calculateTotalPrice(items) {
let totalPrice = 0;
for (let i = 0; i < items.length; i++) {
totalPrice += items[i].price;
}
return totalPrice;
}

Keeping functions “pure” and independent: A pure function is one that has no side effects and always returns the same output for the same input. This makes the code easier to reason about and test.

// Impure function
let taxRate = 0.1;
function calculateTax(subtotal) {
return subtotal * taxRate;
}

// Refactored pure function
function calculateTax(subtotal, taxRate) {
return subtotal * taxRate;
}

#3: Magic, magic everywhere!

The third best practice for writing clean code is to avoid using magic numbers or magic strings. A magic number or string is a value that appears in code without any explanation or context, making it difficult to understand and maintain.

For example, imagine you’re working on a codebase that has the following line of code:

if (statusCode === 404) {
// Do something
}

In this case, 404 is a magic number. It's not clear why this number is used, and it could be confusing for someone who is not familiar with the code.

To avoid this issue, you can define a constant or variable to represent the magic number:

const NOT_FOUND = 404;

if (statusCode === NOT_FOUND) {
// Do something
}

Here are a few more examples of how to avoid magic numbers or strings in your code:

  1. Use constants for commonly used values:
const PI = 3.14159;
const MAX_ATTEMPTS = 5;

let circleArea = PI * radius * radius;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
// Try something up to 5 times
}

2. Use enums or objects for discrete values:

const Color = {
RED: 'red',
BLUE: 'blue',
GREEN: 'green'
};

let carColor = Color.RED;
if (carColor === Color.BLUE) {
// Do something
}

3. Use descriptive variable names:

const SECONDS_IN_AN_HOUR = 3600;

let timeElapsedInSeconds = 5400; // 1.5 hours
let timeElapsedInHours = timeElapsedInSeconds / SECONDS_IN_AN_HOUR;

And remember, while magic tricks can be impressive on stage, magic numbers and strings have no place in your code!

woman blowing on her hand and magic fireworks appear
Photo by Almos Bechtold on Unsplash

#4: Make functions even smaller! — One function, one responsibility

As I wrote earlier — When writing code, it’s important to keep functions small and focused. Each function should have one clear responsibility, making it easier to understand, test, and maintain.

Large, monolithic functions can be difficult to work with, and can make it harder to identify and fix bugs. By breaking functions down into smaller, focused pieces, you can create code that is more modular and easier to work with.

Here’s an example of a large, monolithic function:

function validateUser(user) {
if (!user) {
return false;
}

if (!user.username || !user.password) {
return false;
}

if (user.username.length < 6 || user.password.length < 8) {
return false;
}

// Check for duplicate username
let users = getUsers();
for (let i = 0; i < users.length; i++) {
if (users[i].username === user.username) {
return false;
}
}

// Check for strong password
let passwordRegex = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}/;
if (!passwordRegex.test(user.password)) {
return false;
}

// Validation successful
return true;
}

This function is doing too many things at once — it’s validating the user object, checking for duplicate usernames, and checking for a strong password. Instead, we can break this function down into smaller, focused pieces:

function validateUser(user) {
if (!user) {
return false;
}

if (!isValidUsername(user.username)) {
return false;
}

if (!isValidPassword(user.password)) {
return false;
}

if (isDuplicateUsername(user.username)) {
return false;
}

// Validation successful
return true;
}

function isValidUsername(username) {
return !!username && username.length >= 6;
}

function isValidPassword(password) {
let passwordRegex = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}/;
return !!password && passwordRegex.test(password);
}

function isDuplicateUsername(username) {
let users = getUsers();
for (let i = 0; i < users.length; i++) {
if (users[i].username === username) {
return true;
}
}

return false;
}

Now, each function has one clear responsibility, and the code is easier to read, understand, and maintain.

#5: Don’t repeat yourself (DRY) — Because copy-pasting is for amateurs

When writing code, it’s important to follow the DRY principle — Don’t Repeat Yourself. This means that you should avoid duplicating code as much as possible, and instead find ways to reuse code that’s already written.

Copying and pasting code might seem like a quick and easy solution, but it often leads to more work in the long run. Duplicate code is harder to maintain and update, and can lead to inconsistencies and bugs.

So instead of copy-pasting, try to find ways to reuse code. This could mean creating functions, classes, or modules that can be reused across your codebase.

Here’s an example of code that violates the DRY principle:

function calculatePrice(quantity, price) {
let taxRate = 0.10;
let subtotal = quantity * price;
let tax = subtotal * taxRate;
let total = subtotal + tax;

return total;
}

function calculateCost(quantity, price) {
let taxRate = 0.10;
let subtotal = quantity * price;
let tax = subtotal * taxRate;
let total = subtotal + tax;

let shippingFee = 10;
let cost = total + shippingFee;

return cost;
}

As you can see, the calculatePrice and calculateCost functions are almost identical - they both calculate the subtotal, tax, and total. Instead of duplicating this code, we can extract it into a separate function:

function calculatePrice(quantity, price) {
let subtotal = quantity * price;
let tax = calculateTax(subtotal);
let total = subtotal + tax;

return total;
}

function calculateCost(quantity, price) {
let subtotal = quantity * price;
let tax = calculateTax(subtotal);
let total = subtotal + tax;

let shippingFee = 10;
let cost = total + shippingFee;

return cost;
}

function calculateTax(subtotal) {
let taxRate = 0.10;
return subtotal * taxRate;
}

Now, the code is DRY — we’re reusing the calculateTax function to avoid duplicating code. And hopefully, you had a chuckle at the idea that copy-pasting is for amateurs!

a towel hanging to dry
Photo by Tristan Gevaux on Unsplash

#7: Prefer composition over inheritance

In object-oriented programming, inheritance is a way to create a new class based on an existing class, inheriting all of its properties and methods. However, overuse of inheritance can lead to a tightly coupled design, making the code difficult to maintain and modify.

A better approach is to use composition, where a class is composed of other objects, each responsible for a specific behavior. This allows for a more flexible design, where behaviors can be added or removed without affecting other parts of the code.

Here’s an example of using composition instead of inheritance:

class Animal {
constructor(name) {
this.name = name;
}
}

class CanFly {
fly() {
console.log(`${this.name} is flying.`);
}
}

class CanSwim {
swim() {
console.log(`${this.name} is swimming.`);
}
}

class Duck extends Animal {
constructor(name) {
super(name);
this.flyBehavior = new CanFly();
this.swimBehavior = new CanSwim();
}

fly() {
this.flyBehavior.fly();
}

swim() {
this.swimBehavior.swim();
}
}

In this example, we have three classes: Animal, CanFly, and CanSwim. Animal is a base class that provides a common name attribute for all animals. CanFly and CanSwim are classes that provide behaviors for flying and swimming, respectively.

The Duck class uses composition to incorporate the behaviors of flying and swimming. It has two attributes, fly_behavior and swim_behavior, which are instances of the CanFly and CanSwim classes, respectively. The Duck class then defines its own fly and swim methods, which delegate to the corresponding methods in the behavior objects.

Using composition in this way allows us to create more flexible and modular code, where behaviors can be easily added or removed without affecting other parts of the code.

#8: Use dependency injection and inversion of control to manage dependencies

Managing dependencies is a critical aspect of software design, as it determines how the different components of the system interact with each other. One common approach to managing dependencies is through dependency injection and inversion of control.

Dependency injection (DI) is a design pattern that allows components to be loosely coupled by injecting their dependencies as opposed to creating them internally. Inversion of control (IoC) is a related pattern that allows components to be decoupled from their concrete implementations by delegating control over the creation and management of objects to an external container or framework.

By using DI and IoC, you can achieve a more modular and testable codebase, as well as decouple components from their concrete implementations, making them more flexible and adaptable to change.

Here’s an example of how DI and IoC can be used in a JavaScript application:

class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}

getUser(userId) {
return this.userRepository.getUserById(userId);
}
}

class UserRepository {
constructor(database) {
this.database = database;
}

getUserById(userId) {
return this.database.query(`SELECT * FROM users WHERE id = ${userId}`);
}
}

class Database {
constructor(connectionString) {
this.connectionString = connectionString;
}

query(queryString) {
// Execute the query using the database connection
}
}

// Create a new database instance with the connection string
const database = new Database('postgres://user:password@localhost/mydatabase');

// Create a new user repository instance with the database instance
const userRepository = new UserRepository(database);

// Create a new user service instance with the user repository instance
const userService = new UserService(userRepository);

// Use the user service to get a user by ID
const user = userService.getUser(123);

In this example, we have three classes: UserService, UserRepository, and Database. The UserService class depends on the UserRepository class, which in turn depends on the Database class.

Instead of creating the dependencies internally, we use DI to inject them as constructor arguments. We also use IoC to delegate the creation and management of objects to an external container, in this case, by manually instantiating the objects and passing them to each other.

Using DI and IoC in this way helps us achieve a more modular and testable codebase, as well as decoupling the components from their concrete implementations, making them more flexible and adaptable to change.

#9: Write automated unit tests to ensure code correctness and robustness

Unit testing is a critical aspect of software development that helps ensure the correctness and robustness of code. Automated unit tests can catch errors early in the development cycle and prevent regressions as the codebase evolves.

To write effective unit tests, you should follow the following best practices:

  1. Write tests for each unit of code: Each function, method, or class should have one or more tests that cover all the possible scenarios and edge cases.
  2. Use a testing framework: A testing framework provides a set of tools and conventions to help you write, organize, and run tests. Popular testing frameworks for JavaScript include Jest, Mocha, and Jasmine.
  3. Use dependency injection and inversion of control: When writing unit tests, it’s important to isolate the code being tested from its dependencies. You can achieve this by using dependency injection and inversion of control to inject mock or stub dependencies.
  4. Use spies to test behavior: Spies are test doubles that allow you to track and verify the behavior of a function or method. You can use them to ensure that the code under test is calling the expected methods with the expected arguments.

Here’s an example of how to use unit testing, dependency injection, inversion of control, and spies in a JavaScript application:

class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}

async createUser(user) {
// Validate the user input
if (!user.name || !user.email) {
throw new Error('Invalid user input');
}

// Save the user in the database
const savedUser = await this.userRepository.saveUser(user);

// Send a welcome email to the user
await this.sendWelcomeEmail(savedUser);

return savedUser;
}

async sendWelcomeEmail(user) {
// Send a welcome email to the user
}
}

class MockUserRepository {
constructor() {
this.users = new Map();
}

async saveUser(user) {
this.users.set(user.id, user);
return user;
}

async getUserById(userId) {
return this.users.get(userId);
}
}

describe('UserService', () => {
let userService;
let mockUserRepository;

beforeEach(() => {
mockUserRepository = new MockUserRepository();
userService = new UserService(mockUserRepository);
});

describe('createUser', () => {
it('should save the user in the repository', async () => {
const user = { id: 1, name: 'John Doe', email: 'john@example.com' };
const savedUser = await userService.createUser(user);

const retrievedUser = await mockUserRepository.getUserById(1);

expect(retrievedUser).toEqual(savedUser);
});

it('should throw an error if the user input is invalid', async () => {
const user = { id: 1, name: 'John Doe' };

await expect(userService.createUser(user)).rejects.toThrow('Invalid user input');
});

it('should send a welcome email to the user', async () => {
const user = { id: 1, name: 'John Doe', email: 'john@example.com' };

// Create a spy for the sendWelcomeEmail method
const spy = jest.spyOn(userService, 'sendWelcomeEmail');

await userService.createUser(user);

expect(spy).toHaveBeenCalledWith(user);
});
});
});

This code example demonstrates the importance of unit testing in software development. In this example, we have a UserService class that depends on a UserRepository class to save and retrieve users from the database. We use dependency injection to inject a mock UserRepository instance in the unit tests.

The UserService class has a method called createUser that creates a new user and saves it in the database. It first validates the user input and throws an error if it’s invalid. Then it saves the user in the database using the injected UserRepository instance. Finally, it sends a welcome email to the user.

We have also created a MockUserRepository class that simulates a database by storing users in a Map. This class has two methods: saveUser and getUserById. The saveUser method stores the user in the Map, and the getUserById method retrieves a user by its ID.

In the unit tests, we create an instance of the UserService class and inject the MockUserRepository instance. Then we write tests for the createUser method that check if it saves the user in the repository, throws an error if the user input is invalid, and sends a welcome email to the user.

We also use a spy to test if the sendWelcomeEmail method is called when a new user is created. A spy is a function that records all calls to it, so we can check if it was called with the correct arguments.

Overall, this example demonstrates how we can use unit testing and dependency injection to test our code and ensure that it works as expected.

#10. Use SonarQube for Continuous Code Quality Inspection

SonarQube is a great tool for inspecting the quality of your code on an ongoing basis. By integrating it with your CI/CD pipeline, you can automatically analyze each new code change for quality issues and get feedback right away. Here are some tips for using SonarQube effectively:

  1. Integrate SonarQube into your CI/CD pipeline: Make sure that SonarQube is part of your automated build process. This will ensure that your code is analyzed for quality issues on every build.
  2. Set quality gates: Quality gates are thresholds that your code must meet before it can be considered for release. Set quality gates for your projects and ensure that all code changes meet those thresholds before they are deployed.
  3. Address issues quickly: When SonarQube flags an issue, address it as quickly as possible. The longer you wait to fix an issue, the more likely it is to become a bigger problem.
  4. Customize the rules: SonarQube comes with a set of pre-defined rules, but you can also customize them to meet your specific needs. Take the time to review the default rules and add any additional rules that are important to your organization.
  5. Analyze the results: Use SonarQube’s dashboards and reports to analyze the results of your code analysis. This will help you identify areas where you need to improve and make changes to your development process.
  6. Involve your team: SonarQube is a tool for the entire team, not just the developers. Make sure that everyone on the team understands how to use it and why it’s important.
  7. Monitor your technical debt: Technical debt is the cost of maintaining your code over time. SonarQube can help you identify areas of your codebase that are more expensive to maintain and allow you to prioritize them for refactoring.

By following these best practices, you can ensure that SonarQube is an effective tool for improving the quality of your code on an ongoing basis.

In conclusion, following best practices is essential for creating high-quality, maintainable, and scalable software systems. By following these practices, developers can reduce the risk of bugs, make code easier to understand and maintain, improve performance, and increase the overall quality of the codebase. Moreover, adhering to best practices can improve collaboration and communication among team members and can ensure that code is consistent and meets industry standards.

a hand holding a lamp bulb in front of a dawning sky
Photo by Diego PH on Unsplash

Want to learn more?

If you want to know more about clean code and maintainability check out some of the following:

  1. Clean Code: A Handbook of Agile Software Craftsmanship (book) by Robert C. Martin
  2. Clean Code JavaScript (book) by Ryan McDermott
  3. Clean Architecture: A Craftsman’s Guide to Software Structure and Design (book) by Robert C. Martin
  4. The Clean Code Blog (blog) by Robert C. Martin
  5. The Clean Code Talks (videos) by Robert C. Martin

If you enjoyed this content and found it helpful, please clap 👏 and follow to show your support 😻. Additionally, if you have any further questions or would like me to produce more content on a specific topic, feel free to ask. Thank you for reading!

--

--

Mattias Tornqvist

Software industry professional since 2016. Fullstack developer & Scrum Master, former Product Owner and experience in software requirements and testing