Refactoring Python: Eliminating 7 Common Code Smells for Cleaner Architecture
The Hidden Cost of Code Smells
A code smell is not a bug. Your program might run perfectly, passing every test case and delivering the expected output. However, code smells are subtle indicators of deeper design flaws that make
Prerequisites and Toolkit
To follow this guide, you should have a solid grasp of __init__ and __repr__.
During this walkthrough, I utilize typing module, specifically
Structural Refactoring: Data and Naming
One of the most frequent smells involves using "too elaborate" data structures. In our initial point-of-sale example, an Order class maintained three separate lists for item names, quantities, and prices. This is a maintenance nightmare; if you update one list but forget the others, your data goes out of sync.
Grouping Related Data
Instead of parallel arrays, we encapsulate these related fields into a single
from dataclasses import dataclass
@dataclass
class LineItem:
item: str
quantity: int
price: int
@property
def total_price(self) -> int:
return self.quantity * self.price
Precision in Naming
We also addressed misleading names. A method titled create_line_item that merely appends an object to a list is better named add_line_item. Precise naming establishes clear responsibility and prevents developers from making incorrect assumptions about what a function does under the hood.
Decoupling Classes and Responsibilities
A class with too many instance variables often indicates it has taken on too many responsibilities. Our Order class originally held every detail of a
The "Ask, Don't Tell" Principle
We also fixed the "verb-subject" smell. Instead of having a system pull data out of an order to calculate a total, we moved that logic into the Order class itself. Using sum function with a generator expression creates a clean, functional approach to this calculation:
@property
def total_price(self) -> int:
return sum(item.total_price for item in self.items)
Advanced Dependency Management
Hard-wired sequences and object creation within initializers are the most dangerous smells because they create rigid coupling. If the POS system creates its own StripePaymentProcessor, you can never test that system without hitting the real
Dependency Injection and Protocols
By using dependency injection, we pass the processor into the initializer. To keep things flexible, we define a POS system doesn't need to know it's using process_payment method.
from typing import Protocol
class PaymentProcessor(Protocol):
def process_payment(self, reference: str, price: int) -> None:
...
class POS:
def __init__(self, payment_processor: PaymentProcessor):
self.payment_processor = payment_processor
Syntax Notes and Best Practices
When refactoring, pay attention to from __future__ import annotations to handle forward references in type hints, allowing a class method to return an instance of its own class before the class is fully defined.
As a final bonus, always prefer keyword arguments. Passing a string of raw integers like (102, 1, 500) to a constructor is ambiguous. Using id=102, quantity=1, price=500 makes the code self-documenting and resilient to changes in argument order. Refactoring isn't just about making code shorter; it's about making the intent behind the code undeniable.
Tips & Gotchas
- The List Factory Trap: In Data Classes, never use
items: list = []. This creates a shared mutable default. Always usefield(default_factory=list). - Law of Demeter: If you see chains like
self.order.customer.address.city, you are leaking implementation details. The calling object knows too much about the internal structure of its neighbors. - Async Initializers: Remember that
__init__cannot beasync. If your object requires an asynchronous setup (like a network connection), use a staticcreatefactory method to handle the awaitable logic before returning the instance.

Fancy watching it?
Watch the full video and context