Clean Code: Tips for Writing Clean and Maintainable Code
Introduction
Who here has ever opened old code they wrote themselves — only to feel like they're reading someone else's work? 😅
"I understand why I wrote this... but why does it look like this?"
Relax, you're not alone. Almost every developer has experienced this. That's exactly why writing Clean Code matters — code that doesn't just run, but is also pleasant to read, easy to understand, and simple to maintain.
Clean Code isn't about making your code look "cool" or using the latest features. Clean Code is about ease — ease for others to understand, ease to maintain, and ease to extend in the future.
In this tutorial, we'll cover practical tips you can apply immediately in your day-to-day projects. No convoluted theory — we'll jump straight into clear code examples.
Why Does Clean Code Matter?
Before diving into the tips, let's understand why this matters:
- Reduces bugs — well-structured code is easier to debug because the structure is clear
- Speeds up onboarding — new developers can understand your code right away
- Simplifies refactoring — organized code is safer to modify
- Saves time — writing clean code upfront is faster than debugging messy code later
"Any fool can write code that a computer can understand. Good programmers write code that humans can understand." — Martin Fowler
Alright, let's jump into the first tip!
1. Naming: Clear Names Make Life Easy
Variable, function, and class names are the first form of communication between your code and its reader. Bad names turn code into a puzzle.
❌ Bad Naming Examples
# What is this? What data? What for?
def proc(d, f):
r = d * f
return rAny developer reading this will immediately wonder: "What data? What factor? What's the return value?"
✅ Good Naming Examples
def calculate_total_price(quantity, unit_price):
total = quantity * unit_price
return totalNow it's crystal clear. This function calculates the total price based on quantity and unit price.
Naming Rules You Can Apply Right Now
Use names that reveal intent:
# ❌ Bad
d = 86400 # What is d?
# ✅ Good
SECONDS_IN_A_DAY = 86400
# ❌ Bad
def process_data(data):
pass
# ✅ Good
def convert_user_addresses_to_geographic_format(addresses):
passAvoid confusing abbreviations:
# ❌ Bad
def calc_pr因 (u, d):
pass
# ✅ Good
def calculate_disk_space_usage(user, disk):
passBoolean names should indicate true/false:
# ❌ Bad
is_active = True # OK
active = True # Less clear
# ❌ Don't use questions
should_send_email = True # OK
send_email = True # Looks like a function nameUse consistent naming for the same concept:
# ❌ Bad — different names for the same concept
def get_user():
pass
def fetch_member():
pass
def retrieve_client():
pass
# ✅ Good — consistently use "user"
def get_user():
pass
def fetch_user():
pass
def find_user():
passTip: If you struggle to name something, that's often a sign your code design needs work. Renaming frequently leads to better refactoring.
2. Short, Focused Functions
The golden rule: one function, one job. If you can add the word "AND" to the function name, it probably needs to be split.
❌ A Function That Does Too Much
def handle_user_registration(request):
# Validate input (10 lines)
if not request.get('email'):
return {'error': 'Email required'}
if not request.get('password'):
return {'error': 'Password required'}
if len(request['password']) < 8:
return {'error': 'Password too short'}
# Check for duplicates (5 lines)
existing = db.query("SELECT * FROM users WHERE email = ?",
request['email'])
if existing:
return {'error': 'Email already exists'}
# Hash password (5 lines)
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(request['password'].encode(), salt)
# Save to database (10 lines)
user_id = db.execute(
"INSERT INTO users (email, password, name) VALUES (?, ?, ?)",
request['email'], hashed, request.get('name', '')
)
# Send welcome email (10 lines)
send_welcome_email(request['email'], request.get('name', ''))
# Log activity (5 lines)
log_activity('user_registered', {'user_id': user_id,
'email': request['email']})
return {'user_id': user_id}This function does 5 different things: validation, duplicate checking, hashing, saving, and sending email. Way too many!
✅ The Refactored Version
def validate_registration_input(request):
if not request.get('email'):
return {'valid': False, 'error': 'Email required'}
if not request.get('password'):
return {'valid': False, 'error': 'Password required'}
if len(request['password']) < 8:
return {'valid': False, 'error': 'Password too short'}
return {'valid': True}
def check_email_availability(email):
existing = db.query("SELECT id FROM users WHERE email = ?", email)
return existing is None
def hash_password(password):
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode(), salt)
def create_user(email, hashed_password, name):
user_id = db.execute(
"INSERT INTO users (email, password, name) VALUES (?, ?, ?)",
email, hashed_password, name
)
return user_id
def handle_user_registration(request):
validation = validate_registration_input(request)
if not validation['valid']:
return {'error': validation['error']}
if not check_email_availability(request['email']):
return {'error': 'Email already exists'}
hashed = hash_password(request['password'])
user_id = create_user(request['email'], hashed,
request.get('name', ''))
send_welcome_email(request['email'], request.get('name', ''))
log_activity('user_registered', {'user_id': user_id})
return {'user_id': user_id}Now every function has one responsibility. handle_user_registration is a clear coordinator — it doesn't need to know the details of password hashing or database storage.
What's the Ideal Function Length?
There's no magic number, but here are general guidelines:
- Ideal: 10-20 lines
- Maximum: 30-40 lines
- If it exceeds 50 lines: It almost certainly needs to be split
What matters more than line count is whether the function is easy to understand in a single read.
3. Don't Repeat Yourself (DRY)
DRY = Don't Repeat Yourself. If you're copy-pasting the same code in multiple places, that's a red flag.
❌ Duplicated Code
def calculate_employee_salary(employee):
base_salary = employee.hourly_rate * employee.hours_worked
tax = base_salary * 0.2
insurance = base_salary * 0.05
final_salary = base_salary - tax - insurance
return final_salary
def calculate_contractor_salary(contractor):
base_salary = contractor.hourly_rate * contractor.hours_worked
tax = base_salary * 0.2
insurance = base_salary * 0.05
final_salary = base_salary - tax - insurance
return final_salaryThe code above has obvious duplication. If the tax calculation changes tomorrow, you'd have to update it in two places!
✅ DRY: One Source of Truth
def calculate_base_salary(worker):
return worker.hourly_rate * worker.hours_worked
def calculate_tax(amount):
return amount * 0.2
def calculate_insurance(amount):
return amount * 0.05
def calculate_final_salary(base_salary):
tax = calculate_tax(base_salary)
insurance = calculate_insurance(base_salary)
return base_salary - tax - insurance
def calculate_employee_salary(employee):
base = calculate_base_salary(employee)
return calculate_final_salary(base)
def calculate_contractor_salary(contractor):
base = calculate_base_salary(contractor)
return calculate_final_salary(base)Now if the tax calculation changes, you only update calculate_tax(). All functions using it update automatically.
When DRY Is Not a Good Idea
There's a concept called "AHA" (Apparently Haemorrhaging Abstractions) — where DRY actually makes code harder to understand due to too much abstraction.
# ❌ Too abstract — more confusing than helpful
def apply_discount_strategy(discount_strategy, cart):
return discount_strategy.apply(cart)
# More readable when read directly:
# ✅ Easier to understand
if user.is_premium:
total = cart.total * 0.8 # 20% discount for premium
elif cart.total > 100:
total = cart.total * 0.9 # 10% discount for big orders
else:
total = cart.totalUse DRY wisely — if the abstraction makes code clearer, go for it. If it makes things confusing, don't force it.
4. SOLID Principles in Plain English
SOLID represents 5 design principles that make code more maintainable. Let's break them down with simple examples.
S — Single Responsibility Principle (SRP)
One class, one responsibility.
# ❌ Violates SRP — this class does too much
class UserManager:
def create_user(self, data):
# User creation logic
pass
def send_welcome_email(self, user):
# Email logic
pass
def generate_report(self, users):
# Report generation logic
pass
def backup_database(self):
# Database backup logic
pass# ✅ Complies with SRP — each class has one responsibility
class UserService:
def create_user(self, data):
pass
class EmailService:
def send_welcome_email(self, user):
pass
class ReportGenerator:
def generate_user_report(self, users):
pass
class DatabaseBackup:
def backup(self):
passO — Open/Closed Principle (OCP)
Open for extension, closed for modification.
# ❌ Must modify every time a new payment type is added
def process_payment(payment_type, amount):
if payment_type == 'credit_card':
# process credit card
pass
elif payment_type == 'bank_transfer':
# process bank transfer
pass
elif payment_type == 'e_wallet':
# process e-wallet
pass
# What if crypto is added tomorrow? Another elif...# ✅ New types can be added without modifying existing code
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
@abstractmethod
def process(self, amount):
pass
class CreditCardProcessor(PaymentProcessor):
def process(self, amount):
print(f"Processing credit card payment: {amount}")
class BankTransferProcessor(PaymentProcessor):
def process(self, amount):
print(f"Processing bank transfer: {amount}")
class EWalletProcessor(PaymentProcessor):
def process(self, amount):
print(f"Processing e-wallet payment: {amount}")
# If crypto is added tomorrow, just create a new class:
class CryptoProcessor(PaymentProcessor):
def process(self, amount):
print(f"Processing crypto payment: {amount}")L — Liskov Substitution Principle (LSP)
Subclasses should be able to replace parent classes without changing the program's behavior.
# ❌ Violates LSP — Square changes when width is modified
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
class Square(Rectangle):
def set_width(self, width):
self.width = width
self.height = width # Height changes too!
# The problem:
rect = Square(5, 5)
rect.set_width(10)
# Now rect.width = 10 and rect.height = 10
# But with a normal Rectangle, height should stay 5# ✅ Avoids the LSP problem
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.sideI — Interface Segregation Principle (ISP)
Don't force a class to implement interfaces it doesn't need.
# ❌ Too many methods in one interface
class Worker(ABC):
@abstractmethod
def work(self):
pass
@abstractmethod
def eat(self):
pass
@abstractmethod
def sleep(self):
pass
# Robot doesn't need eat() or sleep()!
class Robot(Worker):
def work(self):
print("Robot working")
def eat(self):
raise Exception("Robots don't eat!") # ❌
def sleep(self):
raise Exception("Robots don't sleep!") # ❌# ✅ Segregated interfaces
class Workable(ABC):
@abstractmethod
def work(self):
pass
class Feedable(ABC):
@abstractmethod
def eat(self):
pass
class Sleepable(ABC):
@abstractmethod
def sleep(self):
pass
class Human(Workable, Feedable, Sleepable):
def work(self):
print("Human working")
def eat(self):
print("Human eating")
def sleep(self):
print("Human sleeping")
class Robot(Workable):
def work(self):
print("Robot working")D — Dependency Inversion Principle (DIP)
High-level modules shouldn't depend on low-level modules. Both should depend on abstractions.
# ❌ Depends directly on a concrete class
class MySQLDatabase:
def query(self, sql):
print(f"Executing: {sql}")
class UserRepository:
def __init__(self):
self.db = MySQLDatabase() # Hard dependency
def find_user(self, user_id):
return self.db.query(f"SELECT * FROM users WHERE id={user_id}")# ✅ Depends on an abstraction (interface)
class Database(ABC):
@abstractmethod
def query(self, sql):
pass
class MySQLDatabase(Database):
def query(self, sql):
print(f"MySQL: {sql}")
class PostgreSQLDatabase(Database):
def query(self, sql):
print(f"PostgreSQL: {sql}")
class UserRepository:
def __init__(self, database: Database):
self.db = database # Injected dependency
def find_user(self, user_id):
return self.db.query(f"SELECT * FROM users WHERE id={user_id}")
# Usage:
mysql_repo = UserRepository(MySQLDatabase())
pg_repo = UserRepository(PostgreSQLDatabase())5. Comments: Right and Wrong
Many developers misunderstand comments. Comments aren't for explaining what the code does — they're for explaining why.
❌ Comments That Explain "What" (Unnecessary)
# Iterate through the list
for item in items:
# Check if item is valid
if item.is_valid:
# Add to valid items list
valid_items.append(item)Comments like this just repeat what's already obvious from the code. They slow down reading!
✅ Comments That Explain "Why"
# Use stable sort because we need to preserve the original
# order of items with equal priority
items.sort(key=lambda x: x.priority, stable=True)
# Skip weekend dates because our payment processor only
# operates on business days
for date in dates:
if date.weekday() < 5:
process_payment(date)✅ TODO and FIXME
# TODO: Implement rate limiting after v2 launch
# FIXME: This breaks when user has no email address
# HACK: Temporary workaround until API returns correct formatWhen You Don't Need Comments
# ❌ Unnecessary comment
# Get user from database by their ID
user = get_user_by_id(user_id)
# ✅ Function name says it all
user = get_user_by_id(user_id) # No comment needed!Rule of thumb: If your code needs lots of comments to be understood, the code probably needs fixing, not more comments.
6. Robust Error Handling
Good error handling makes code more reliable and easier to debug.
❌ Swallowing Errors
def read_config_file(path):
try:
with open(path) as f:
return json.load(f)
except:
pass # Error? Just ignore it...When errors are swallowed like this, debugging becomes a nightmare. You'll never know why the code isn't working.
✅ Clear Error Handling
import logging
def read_config_file(path):
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError:
logging.error(f"Config file not found: {path}")
raise
except json.JSONDecodeError as e:
logging.error(f"Invalid JSON in {path}: {e}")
raise
except Exception as e:
logging.error(f"Unexpected error reading {path}: {e}")
raise✅ Custom Exceptions
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f"Insufficient funds: balance {balance}, "
f"requested {amount}"
)
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount
# Usage:
try:
new_balance = withdraw(100, 150)
except InsufficientFundsError as e:
print(f"Failed: {e}")
# Output: Failed: Insufficient funds: balance 100, requested 150✅ Fail Fast
def process_order(order):
if order is None:
raise ValueError("Order cannot be None")
if not order.items:
raise ValueError("Order must have at least one item")
if order.total <= 0:
raise ValueError("Order total must be positive")
# Only process if all validations pass
return _process_order_internal(order)7. Refactoring: Continuously Improving Code
Clean Code isn't achieved in one shot. It requires refactoring — improving code structure without changing its behavior.
When Should You Refactor?
- Code Smell: Code that feels "wrong" but isn't necessarily a bug
- Duplication: The same code appears in multiple places
- Functions that are too long: Over 30-50 lines
- Too many parameters: More than 3-4 parameters
- Deep nesting: More than 3 levels of indentation
Common Refactoring Techniques
1. Extract Method
# ❌ Before refactoring
def generate_invoice(order):
# ... 50 lines of code ...
pass
# ✅ After refactoring
def generate_invoice(order):
header = create_invoice_header(order)
items = format_invoice_items(order.items)
footer = calculate_totals(order)
return render_invoice(header, items, footer)2. Rename Variable/Function
# ❌ Before
x = get_d()
t = x * 0.2
# ✅ After
tax_rate = get_discount_rate()
tax_amount = subtotal * tax_rate3. Replace Magic Numbers with Named Constants
# ❌ Before
if speed > 186282:
print("Don't use!")
# ✅ After
SPEED_OF_LIGHT = 186282 # miles per second
if speed > SPEED_OF_LIGHT:
print("Don't use!")Incremental Refactoring (Strangler Fig Pattern)
For large codebases, don't try to refactor everything at once. Take an incremental approach:
- Identify areas that need improvement
- Write tests to ensure behavior stays the same
- Refactor little by little
- Run tests every time you make a change
- Repeat until the area is clean
8. Clean Code Checklist
Here's a quick checklist you can use before pushing code:
- Variable and function names are clear and reveal intent
- Functions are short — one function, one job
- No duplication — the same concept isn't written twice
- Comments explain "why", not "what"
- Error handling doesn't swallow errors
- Magic numbers replaced with named constants
- Consistent indentation — max 3-4 levels
- Few parameters — ideally 0-3
- Consistent return values — no mixing return types
- Code has been reviewed at least once before merging
Conclusion
Clean Code isn't about following rigid rules. It's about empathy — empathy for the other developers who will read your code, and empathy for your future self.
Start small:
- Fix confusing variable names
- Split functions that are too long
- Remove duplication in your code
- Add comments that explain why, not what
Every time you write code, ask yourself: "Can someone else understand this — including my future self six months from now?"
If the answer is "not yet," it's time to refactor! 😄
Happy clean coding! 🧹✨
More posts
JsonViewer: Interactive JSON Visualization
· 9 min read
Git Branching Strategy: Branching Models for Teams
· 3 min read