Antwa CodeAntwaCode Blog

Programming5 min read

Clean Code: Tips for Writing Clean and Maintainable Code

Tips for writing clean and maintainable code.

Read in Bahasa Indonesia

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 r

Any 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 total

Now 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):
    pass

Avoid confusing abbreviations:

# ❌ Bad
def calc_pr因 (u, d):
    pass
 
# ✅ Good
def calculate_disk_space_usage(user, disk):
    pass

Boolean 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 name

Use 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():
    pass

Tip: 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_salary

The 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.total

Use 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):
        pass

O — 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.side

I — 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 format

When 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_rate

3. 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:

  1. Identify areas that need improvement
  2. Write tests to ensure behavior stays the same
  3. Refactor little by little
  4. Run tests every time you make a change
  5. 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:

  1. Fix confusing variable names
  2. Split functions that are too long
  3. Remove duplication in your code
  4. 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