One of the most common pieces of feedback we provide to junior developers is to focus on making the code more maintainable for future development.
As junior programmers, we’re not usually thinking about future development. “Maintenance? I just want my code to work!”
But as you get more comfortable coding, it’s important to start focusing on shipping code that marries functionality with longevity, and velocity with collaboration.
In this post, we’ll look at some of the simplest ways to improve the maintainability of code, with some basic examples.
What Makes Code Maintainable?
Maintainable code refers to software that is structured and written in such a way that it can be easily understood, modified, extended, and debugged by someone other than its original author, often with minimal effort or risk of introducing new bugs. The goal of maintainable code is to ensure that a project is sustainable over time, adapting to new requirements or technologies without requiring a complete rewrite.
Some Best Practices for Writing Maintainable Code
Here are some ways to get started with making your code more maintainable. Consider these the low-hanging fruit that will cover 75-80% of the causes of difficult-to-maintain code.
Write Readable Code:
Use clear, descriptive names for variables, functions, and classes. Ideally, anyone reading your code will understand its intent purely from the naming conventions.
Follow a consistent coding style and conventions. Use a code formatter like Prettier or Biome to clean up your code.
Keep functions and methods focused on a single task. This not only helps your code readability but makes it easier to add features in the future without requiring too many alterations to existing code.
Document Your Code:
Maintain updated documentation for how to use and contribute to the codebase. Your README is the starting place, but doesn't have to be exhaustive.
Use comments sparingly and make sure they're meaningful. If your naming conventions are descriptive, you shouldn't need very many comments, so keep them to a minimum and use only when necessary. Be sure to explain the "why" behind complex logic, not just the "what."
Refactor to Simplify and Optimize:
Continuously improve and simplify the codebase without changing its external behavior.
Aim for simplicity in your solutions. Complex code is harder to maintain.
Break down large functions into smaller, more manageable pieces.
Keep Your Code DRY:
"Don't Repeat Yourself" - avoid duplicating code by using functions, classes, and modules to encapsulate reusable logic
Similar to point 1, as much as possible, aim to keep functions and methods focused on a single task.
Write Tests:
Practice Test-Driven Development (TDD) where practical, writing tests before writing the code they test.
Implement unit tests to cover critical paths in your code, ensuring that changes do not break existing functionality.
Keep Dependencies Minimal:
- Be cautious about adding external libraries and frameworks. Each addition increases the complexity and potential maintenance burden.
Writing maintainable code is a skill that improves with practice and experience. You’ll also get better through code reviews - both giving and receiving them helps you learn best practices.
Examples of Maintainable Code
Let’s look at a few examples contrasting readable and maintainable code with code that's less maintainable.
Example 1: Variable Naming (Python)
Less Maintainable:
def calculate(d, r):
return d * r
In this snippet, it's unclear what d
and r
represent, and what the function is calculating.
More Maintainable:
def calculate_distance(speed, rate):
return speed * rate
Here, the use of descriptive names (calculate_distance
, speed
and rate
) makes the function's purpose and the meaning of its parameters immediately clear.
The same principle applies as the logic gets more complicated. Say for example you wanted to calculate how much fuel is used during a trip. You probably want functions and variables for the amount of fuel used, the distance traveled, and the resulting calculation. You might be tempted to use a name like mpg
(miles per gallon) for the resulting calculation. But then this parameter name itself needs definition, and is constrained to the imperial system rather than metric system. A better name might be fuel_economy
, as this relates to the goal of the calculation, rather than the specific units of a particular calculation.
This has the added benefit of enabling extensibility, as you could let users localize the calculation.
Example 2: Function Length and Single Responsibility (Python)
Less Maintainable:
def process_data(data):
# Filter out invalid data
data = [d for d in data if d.is_valid]
# Convert data to JSON
data = [d.to_json() for d in data]
# Save data to file
with open('data.json', 'w') as file:
for d in data:
file.write(f"{d}\\n")
# Send notification about data processing
print("Data processing complete.")
This function does too much, making it hard to read, test, and debug.
More Maintainable:
def filter_valid_data(data):
return [d for d in data if d.is_valid]
def convert_data_to_json(data):
return [d.to_json() for d in data]
def save_data_to_file(data, filename):
with open(filename, 'w') as file:
for d in data:
file.write(f"{d}\\n")
def process_data(data):
data = filter_valid_data(data)
data = convert_data_to_json(data)
save_data_to_file(data, 'data.json')
print("Data processing complete.")
By breaking the functionality into smaller, single-responsibility functions, the code becomes easier to understand, maintain, and test.
Example 3: Hardcoded Values to Constants (JavaScript)
Less-Maintainable Code:
function calculateDiscount(price) {
return price - (price * 0.15);
}
console.log(calculateDiscount(100)); // Applies a 15% discount
Improved Code:
const DISCOUNT_RATE = 0.15;
function calculateDiscount(price) {
return price - (price * DISCOUNT_RATE);
}
console.log(calculateDiscount(100)); // Applies a 15% discount
By using a constant (DISCOUNT_RATE
), the improved code makes the discount rate easily adjustable and the purpose of the value clearer. This change enhances readability and maintainability.
Example 4: Complex Conditional Logic to Function (JavaScript)
Less-Maintainable Code:
let userRole = 'admin';
let accessLevel;
if (userRole === 'admin') {
accessLevel = 'full';
} else if (userRole === 'editor') {
accessLevel = 'partial';
} else if (userRole === 'viewer') {
accessLevel = 'read-only';
} else {
accessLevel = 'none';
}
Improved Code:
function determineAccessLevel(userRole) {
const roles = {
admin: 'full',
editor: 'partial',
viewer: 'read-only',
};
return roles[userRole] || 'none';
}
let userRole = 'admin';
let accessLevel = determineAccessLevel(userRole);
Refactoring the conditional logic into a separate function (determineAccessLevel
) with a roles mapping object makes the code cleaner and more modular. It's easier to manage and understand the relationship between user roles and access levels.
Examples of Maintainable Markup
Example 1: Reducing Repetition with CSS Variables
Less-Maintainable CSS:
.header {
color: #333;
background-color: #f3f3f3;
}
.footer {
background-color: #f3f3f3;
}
.button {
color: #333;
background-color: #0088cc;
}
Improved CSS:
:root {
--text-color: #333;
--bg-color: #f3f3f3;
--button-color: #0088cc;
}
.header {
color: var(--text-color);
background-color: var(--bg-color);
}
.footer {
background-color: var(--bg-color);
}
.button {
color: var(--text-color);
background-color: var(--button-color);
}
By using CSS variables for common color values, the improved code makes it easy to change these values in one place (:root
), affecting all related selectors. This approach enhances consistency and maintainability.
Example 2: Combining Selectors for Common Styles
Less-Maintainable CSS:
.header {
font-family: Arial, sans-serif;
}
.footer {
font-family: Arial, sans-serif;
}
.button {
font-family: Arial, sans-serif;
}
Improved CSS:
.header, .footer, .button {
font-family: Arial, sans-serif;
}
By combining selectors that share the same style declarations, the improved code reduces repetition and makes it easier to manage and update the font family for these elements together, improving maintainability.
Example 3: Using Mixins for Repeated Patterns with Preprocessors
Less-Maintainable CSS:
.button {
padding: 10px 15px;
border-radius: 5px;
background-color: #0088cc;
color: #fff;
}
.alert {
padding: 10px 15px;
border-radius: 5px;
background-color: #ff4136;
color: #fff;
}
Improved CSS (Using SCSS):
@mixin button-styles($bg-color) {
padding: 10px 15px;
border-radius: 5px;
background-color: $bg-color;
color: #fff;
}
.button {
@include button-styles(#0088cc);
}
.alert {
@include button-styles(#ff4136);
}
This improvement introduces a mixin for shared styles, parameterizing the background color to make the code more reusable and maintainable. By using a preprocessor like SCSS, common patterns are abstracted into mixins, allowing for more concise and flexible code.
Conclusion
Readable and maintainable code typically involves using descriptive naming, keeping functions focused on a single task, and handling errors gracefully. These practices make the code easier to understand for other developers (and your future self), contributing to a more maintainable and sustainable codebase.
What techniques do you use to keep your code maintainable? And how much of your code's maintainability comes through refactoring vs. through your ingrained practices?