Code Smells — A Practical Refactoring Guide

A working reference to the classic code smells — the surface symptoms of deeper design trouble — and the refactorings that clear them, each shown as wrong-versus-correct code.

Student edition · understand, apply, verify

Use maintainability symptoms to identify a behavior-preserving refactoring, not to add patterns automatically.

What you should be able to do: State the specific maintenance problem, preserve the observable contract, and test outputs, errors, and mutation.

Assumes basic programming. Define input and observable-behavior contracts before applying an algorithm or design heuristic. Pseudocode/fragments need surrounding context; the downloadable lab is complete and tested.

A code smell is a hint. It is a pattern in your source that, more often than not, signals a design weakness worth addressing. The value of the vocabulary is speed: once a team shares names like “Feature Envy” or “Shotgun Surgery,” a reviewer can point at a problem in two words instead of a paragraph, and everyone knows roughly what fix to reach for. This guide walks the five patterns of smells and, for every individual smell, gives a short summary and a side-by-side wrong/correct example so the fix is concrete.

The term “code smell” and this catalog come from Martin Fowler and Kent Beck’s Refactoring. This is an original practical summary for quick reference; the code examples are illustrative. See the book for the full treatment.

Contents

  1. What is a code smell
  2. Pattern 1: Bloaters
  3. Pattern 2: Object-Orientation Abusers
  4. Pattern 3: Change Preventers
  5. Pattern 4: Dispensables
  6. Pattern 5: Couplers
  7. How to use smells well
  8. Summary
  9. Concepts explained
  10. Worked example
  11. Practice and verification

Concepts explained in this guide

Read these definitions first, or use the linked terms as you go. The examples and limitations are local to this guide; companion pages are optional.

Refactoring and code smells

Refactoring changes internal structure while preserving required behavior. A smell is a heuristic pointing to possible maintainability trouble, not proof of a bug.

Example. Extract repeated validation into a named function and test valid outputs and invalid-input exceptions.

Boundary. Do not mechanically split cohesive logic or add indirection. Define the maintainability problem and regression evidence first.

Liskov substitution and interfaces

An implementation preserves its interface's behavioral expectations, not only method names. Avoid requiring more or promising less than callers expect.

Example. A read-only collection cannot pretend append always succeeds under a mutable-collection contract.

Boundary. Include errors, side effects, ownership, and state/history guarantees. Matching types alone does not establish substitutability.

Preconditions and postconditions

A precondition states required inputs/state before an operation. A postcondition states the result promised when those requirements hold and execution terminates normally. Together they make requirements testable.

Example. For sum_first(b,n), require integer 0≤n≤len(b) and numeric elements; promise the sum of b[0:n].

Boundary. State exceptions, mutation, numeric domains, and empty cases. A mathematical array range does not automatically validate a runtime input.

Aliasing and frame conditions

Aliasing means multiple names refer to one mutable object. A frame condition specifies state the operation leaves unchanged.

Example. A sorted-copy function promises it does not mutate its input. Two swap parameters may unexpectedly name one location.

Boundary. State ownership, shared references, mutation, and exception behavior. Local-variable reasoning alone does not protect aliased global state.

Strategy, Adapter, and Observer

Strategy selects behavior behind one contract; Adapter translates interfaces; Observer notifies subscribers. They solve different coupling problems.

Example. Inject a pricing function, wrap a legacy API, or notify subscribers about a local change.

Boundary. Specify error translation, units, subscriber reentrancy/lifetime, and unsubscribe behavior. Pattern names do not prove correctness.

What is a code smell

A code smell is a surface symptom that usually points to a deeper design problem. It is not a bug — the program can compile, pass its tests, and ship with every smell in this guide intact. What a smell tells you is that the code will be harder than it should be to read, change, or extend later. It is a prompt to look closer, not a verdict.

The critical caveat: smells are heuristics, not rules. A long method in a hot loop may be exactly right; a tiny class that looks “lazy” may earn its keep as a domain concept. The discipline is to notice the smell, ask whether it is actually hurting you, and refactor only when the answer is yes. Treat the catalog as a checklist for your intuition, never as a linter that must be obeyed.

Five patterns of code smells
The catalog groups into five patterns. Most smells you meet in practice are one of these; the pattern hints at the kind of fix.

Pattern 1: Bloaters

Bloaters are code, methods, and classes that have grown so large they are hard to work with. They rarely start big — they accrete, one “just one more line” at a time, until nobody wants to touch them.

1.1 Long Method

What it is: a function doing too many things at too many levels of detail.
Why it matters: it is hard to read, hard to test in parts, and hides reusable logic. The fix is Extract Method — pull each coherent chunk into a well-named helper until the original reads like a summary.

✗ Wrong
def print_invoice(order):
    # validate
    if not order.items:
        raise ValueError("empty order")
    # calculate
    total = 0
    for item in order.items:
        total += item.price * item.qty
    if order.customer.is_gold:
        total *= 0.85
    # format
    print(f"Invoice for {order.customer.name}")
    print(f"Total: ${total:.2f}")
✓ Correct
def print_invoice(order):
    _validate(order)
    total = _order_total(order)
    _render(order.customer, total)


def _validate(order):
    if not order.items:
        raise ValueError("empty order")


def _order_total(order):
    subtotal = sum(i.price * i.qty for i in order.items)
    return subtotal * 0.85 if order.customer.is_gold else subtotal


def _render(customer, total):
    print(f"Invoice for {customer.name}")
    print(f"Total: ${total:.2f}")

1.2 Large Class

What it is: a class with too many fields and unrelated responsibilities.
Why it matters: it becomes a magnet for change and impossible to reason about. The fix is Extract Class — split each cohesive responsibility into its own collaborator.

✗ Wrong
class User:                     # does everything
    def __init__(self, name, email):
        self.name = name
        self.email = email
    def save(self):             # persistence
        db.insert("users", self.__dict__)
    def check_password(self, pw):   # auth
        return bcrypt.verify(pw, self.hash)
    def send_welcome(self):     # notifications
        smtp.send(self.email, "Welcome!")
✓ Correct
@dataclass
class User:
    name: str
    email: str

class UserRepository:
    def save(self, u): db.insert("users", asdict(u))

class Authenticator:
    def check(self, u, pw): return bcrypt.verify(pw, u.hash)

class Mailer:
    def welcome(self, u): smtp.send(u.email, "Welcome!")

1.3 Long Parameter List

What it is: four, five, six parameters that are hard to remember and easy to transpose.
Why it matters: call sites are error-prone and the signature churns. The fix is Introduce Parameter Object so related arguments travel together.

✗ Wrong
def book_flight(origin, dest, depart, ret,
                adults, children,
                seat_class, currency):
    ...

book_flight("SFO", "JFK", d1, d2,
            2, 1, "economy", "USD")
✓ Correct
@dataclass
class FlightQuery:
    origin: str
    dest: str
    depart: date
    ret: date | None = None
    adults: int = 1
    children: int = 0
    seat_class: str = "economy"
    currency: str = "USD"

def book_flight(q: FlightQuery):
    ...

1.4 Data Clumps

What it is: the same few data items appearing together in many places (say start, end, tz).
Why it matters: the grouping is a hidden concept, and logic over it gets duplicated. The fix is Extract Class — make the clump a type with its own behavior.

✗ Wrong
def overlaps(start1, end1, tz1,
             start2, end2, tz2):
    ...

def duration(start, end, tz):
    ...
# the trio travels together everywhere
✓ Correct
@dataclass(frozen=True)
class TimeRange:
    start: datetime
    end: datetime
    tz: str

    def overlaps(self, other: "TimeRange") -> bool:
        ...
    def duration(self):
        return self.end - self.start

1.5 Primitive Obsession

What it is: using raw strings and ints to model concepts that deserve a type (a currency amount as a bare float).
Why it matters: validation and behavior get scattered, and illegal states are representable. The fix is to replace the primitive with a small value object.

✗ Wrong
def add_tax(amount, currency, rate):
    # amount is a bare float, currency a loose str
    # nothing stops mixing USD with EUR,
    # or float rounding drift on money
    return round(amount * (1 + rate), 2)

price = 19.99
total = add_tax(price, "USD", 0.08)
✓ Correct
@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str

    def add_tax(self, rate: Decimal) -> "Money":
        return Money(self.amount * (1 + rate),
                     self.currency)

total = Money(Decimal("19.99"), "USD").add_tax(Decimal("0.08"))

Pattern 2: Object-Orientation Abusers

These smells are incomplete or incorrect uses of object-oriented mechanisms — polymorphism, inheritance, interfaces — where the code fights the paradigm instead of leaning on it.

2.1 Switch Statements

What it is: a switch or if/elif chain branching on a type code, especially when the same chain reappears elsewhere.
Why it matters: every new type forces edits in every chain. The fix is Replace Conditional with Polymorphism.

✗ Wrong
def pay(employee):
    if employee.kind == "hourly":
        return employee.hours * employee.rate
    elif employee.kind == "salaried":
        return employee.salary / 12
    elif employee.kind == "commissioned":
        return employee.base + employee.sales * 0.1
    raise ValueError(employee.kind)
✓ Correct
class Employee:
    def pay(self) -> Decimal:
        raise NotImplementedError

class Hourly(Employee):
    def pay(self): return self.hours * self.rate

class Salaried(Employee):
    def pay(self): return self.salary / 12

class Commissioned(Employee):
    def pay(self): return self.base + self.sales * 0.1

2.2 Refused Bequest

What it is: a subclass that inherits methods or data it does not want and cannot use.
Why it matters: it signals the inheritance is wrong and sets a trap for callers. The fix is to restructure the hierarchy (or replace inheritance with delegation) so a class only inherits what it uses.

✗ Wrong
class Bird:
    def fly(self): ...
    def lay_egg(self): ...

class Penguin(Bird):
    def fly(self):
        # inherited but nonsensical
        raise NotImplementedError("can't fly")
✓ Correct
class Bird:
    def lay_egg(self): ...

class FlyingBird(Bird):
    def fly(self): ...

class Penguin(Bird):        # only inherits what it uses
    def swim(self): ...

2.3 Temporary Field

What it is: a field that is only set and meaningful during part of an object’s life, sitting empty the rest of the time.
Why it matters: readers cannot tell when it is valid, inviting bugs. The fix is to make it a local, or Extract Class for the field and its logic.

✗ Wrong
class Calculator:
    def compute(self, data):
        self._scratch = self._prepare(data)  # temp field
        return self._finish()

    def _finish(self):
        # only valid mid-compute
        return sum(self._scratch)
✓ Correct
class Calculator:
    def compute(self, data):
        scratch = self._prepare(data)  # local, not a field
        return self._finish(scratch)

    def _finish(self, scratch):
        return sum(scratch)

2.4 Alternative Classes with Different Interfaces

What it is: two classes that do the same job but expose different method names and signatures.
Why it matters: callers must special-case which one they hold, so they cannot be swapped. The fix is to align the interfaces (Rename Method, Move Method) behind a common contract.

✗ Wrong
class FileLog:
    def write_line(self, msg): ...

class CloudLog:
    def push(self, text): ...   # same job, different API

# callers must know which one they have
✓ Correct
class Log(Protocol):
    def log(self, msg: str) -> None: ...

class FileLog:
    def log(self, msg): ...

class CloudLog:
    def log(self, msg): ...      # now interchangeable

Pattern 3: Change Preventers

These smells make change expensive: one conceptual change forces edits in many unrelated places, or in the wrong number of places. They are the most direct tax on velocity.

3.1 Divergent Change

What it is: one class that changes for many different reasons (the database and the tax rules and the report layout all touch it).
Why it matters: unrelated changes collide in one file. The fix is Extract Class so each reason to change lives in one place.

✗ Wrong
class SalesReport:
    def load(self):
        db.query(...)      # changes with the DB
    def compute(self):
        ...                # changes with the math
    def render_pdf(self):
        ...                # changes with the layout
✓ Correct
class SalesData:       # only the data source
    def load(self): db.query(...)

class SalesMetrics:    # only the math
    def compute(self, data): ...

class SalesPdf:        # only the layout
    def render(self, metrics): ...

3.2 Shotgun Surgery

What it is: the mirror image — one change forces small edits scattered across many classes.
Why it matters: it is easy to miss a spot, and every change is a hunt. The fix is Move Method/Move Field to pull the scattered responsibility into one home.

✗ Wrong
class Cart:
    def total(self): return self.sub * 1.08
class Invoice:
    def total(self): return self.sub * 1.08
class Quote:
    def total(self): return self.sub * 1.08
# change the tax rate -> edit every class
✓ Correct
class Tax:
    RATE = 0.08
    @staticmethod
    def apply(amount):
        return amount * (1 + Tax.RATE)

class Cart:
    def total(self): return Tax.apply(self.sub)
class Invoice:
    def total(self): return Tax.apply(self.sub)

3.3 Parallel Inheritance Hierarchies

What it is: every time you add a subclass in one hierarchy you must add a matching subclass in another.
Why it matters: the duplication grows without bound. The fix is to let one hierarchy own the behavior so the parallel one disappears.

✗ Wrong
class Circle(Shape): ...
class Square(Shape): ...

class CircleRenderer(Renderer): ...
class SquareRenderer(Renderer): ...
# add a shape -> add a matching renderer
✓ Correct
class Shape:
    def render(self, canvas): ...  # shape draws itself

class Circle(Shape):
    def render(self, canvas): canvas.circle(...)

class Square(Shape):
    def render(self, canvas): canvas.rect(...)

Pattern 4: Dispensables

Dispensables are things whose absence would make the code cleaner. They add volume and reading cost without adding value.

4.1 Duplicate Code

What it is: the same structure in more than one place — the single most common smell.
Why it matters: a fix must be applied everywhere, and one copy always gets missed. The fix is Extract Method (or Pull Up Method across subclasses).

✗ Wrong
def area_cm(w_mm, h_mm):
    return (w_mm / 10) * (h_mm / 10)

def perimeter_cm(w_mm, h_mm):
    return 2 * (w_mm / 10 + h_mm / 10)
# the mm->cm conversion is copied
✓ Correct
def _cm(mm):
    return mm / 10

def area_cm(w_mm, h_mm):
    return _cm(w_mm) * _cm(h_mm)

def perimeter_cm(w_mm, h_mm):
    return 2 * (_cm(w_mm) + _cm(h_mm))

4.2 Dead Code

What it is: a variable, parameter, branch, method, or class no longer used.
Why it matters: it misleads readers into thinking it matters and inflates maintenance. The fix is to delete it — version control remembers it if you ever need it back.

✗ Wrong
def price(order, legacy_flag=False):  # never used
    discount = 0          # computed, never read
    total = order.subtotal
    if False:             # unreachable
        total *= 0.9
    return total
✓ Correct
def price(order):
    return order.subtotal

4.3 Lazy Class

What it is: a class that no longer does enough to justify its existence.
Why it matters: it adds indirection and a file to maintain for no benefit. The fix is Inline Class, folding it into its only caller.

✗ Wrong
class Name:
    def __init__(self, value):
        self.value = value
    def get(self):
        return self.value   # adds nothing over a str

user.name = Name("Ada")
print(user.name.get())
✓ Correct
user.name = "Ada"       # just use the string
print(user.name)

4.4 Speculative Generality

What it is: abstractions, hooks, and parameters built for a future that never came.
Why it matters: the unused flexibility is pure cost — more code, more indirection, harder reading. The fix is to collapse it. YAGNI.

✗ Wrong
class AbstractExporterFactory:   # only ever makes CSV
    def create(self, kind="csv", **future_opts):
        return CsvExporter()

exporter = AbstractExporterFactory().create()
✓ Correct
exporter = CsvExporter()
# add a factory when a 2nd format
# actually appears, not before

4.5 Data Class

What it is: a class that is only fields and getters/setters, with all the behavior that operates on it living elsewhere.
Why it matters: logic drifts away from the data it needs (often causing Feature Envy). The fix is Move Method to bring behavior onto the data.

✗ Wrong
@dataclass
class Rectangle:
    width: float
    height: float

def area(r):            # behavior lives away
    return r.width * r.height
✓ Correct
@dataclass
class Rectangle:
    width: float
    height: float

    def area(self):     # behavior on its data
        return self.width * self.height

4.6 Comments as Deodorant

What it is: a block of comments explaining what a tangle of code does, masking a smell.
Why it matters: comments rot and the tangle stays. The fix is to Extract Method named after the comment; keep comments that explain why, not what.

✗ Wrong
# check if user is an active premium subscriber
if (u.plan == "premium"
        and u.status == "active"
        and u.expires > today()):
    grant_access()
✓ Correct
if is_active_premium(u):
    grant_access()

def is_active_premium(u):
    return (u.plan == "premium"
            and u.status == "active"
            and u.expires > today())

Pattern 5: Couplers

Couplers are smells of excessive coupling between classes — or, in the case of Middle Man, of coupling avoided so aggressively that a class stops pulling its weight.

5.1 Feature Envy

What it is: a method more interested in another class’s data than its own, reaching across to grab several of its fields.
Why it matters: it couples the two classes and splits behavior from data. The fix is Move Method to put the behavior where the data lives.

✗ Wrong
class Order:
    def total(self, customer):
        # reaches into customer for everything
        discount = customer.tier_discount
        credit = customer.loyalty_credit
        return (self.subtotal * (1 - discount)
                - credit)
✓ Correct
class Customer:
    def net_of(self, subtotal):
        return (subtotal * (1 - self.tier_discount)
                - self.loyalty_credit)

class Order:
    def total(self, customer):
        return customer.net_of(self.subtotal)

5.2 Inappropriate Intimacy

What it is: two classes reaching deep into each other’s private internals.
Why it matters: neither can change without breaking the other. The fix is Move Method/Move Field to shrink the shared surface and talk through interfaces.

✗ Wrong
class Customer:
    def __init__(self):
        self._orders = []

class Order:
    def link(self, c):
        c._orders.append(self)      # into privates
        self._tier = c._tier
✓ Correct
class Customer:
    def add_order(self, o):
        self._orders.append(o)      # own your data

class Order:
    def link(self, c):
        c.add_order(self)           # via interface

5.3 Message Chains

What it is: a client walking a long path like a.b().c().d().
Why it matters: the caller is coupled to the whole navigation structure, so any intermediate change breaks it. The fix is Hide Delegate — give the first object a method that hides the chain.

✗ Wrong
city = (order.get_customer()
             .get_address()
             .get_city())
# coupled to the whole path
✓ Correct
class Order:
    def customer_city(self):
        return self._customer.city()   # hide delegate

city = order.customer_city()

5.4 Middle Man

What it is: a class where most methods just delegate to another class, adding nothing.
Why it matters: it is indirection for its own sake. The fix is Remove Middle Man and let clients talk to the real object.

✗ Wrong
class Manager:
    def __init__(self, worker):
        self._w = worker
    def do_task(self):
        return self._w.do_task()
    def report(self):
        return self._w.report()
    # adds nothing but forwarding
✓ Correct
# drop the middle man; use the worker directly
worker = Worker()
worker.do_task()
worker.report()

How to use smells well

Knowing the catalog is the easy part; using it without wrecking a working system is the skill. The loop below keeps refactoring safe — behavior never changes, only structure.

The safe refactoring loop
Refactor in a tight loop: one small structural change at a time, with a green test suite between every step.
Boy Scout rule in practice
The Boy Scout rule in four moves: while you are already in a file, make one small safe improvement and move on.

Worked example: extract validation without changing behavior

Use this small example to connect the concept to a concrete decision.

extract validation without changing behaviorDefine current behavior: Document valid inputs, error types/messages where contractual, mutation, and output order.. Identify the smell: Repeated validation has drifted between two entry points; this is an observed maintenance cost, not just an aesthetic dislike.. Extract one cohesive function: Pass required data explicitly and keep semantic defaults/errors identical. Do not silently broaden allowed inputs during a refactor.. Run characterization/regression tests: Compare old/new outputs and failures on boundary inputs; separately propose intentional behavior changes with their own tests.1Define current behavior2Identify the smell3Extract one cohesive function4Run characterization/regression tests
Sequence for this example. The table explains the evidence and decision at each step.
StepEvidence, decision, and expected result
Define current behaviorDocument valid inputs, error types/messages where contractual, mutation, and output order.
Identify the smellRepeated validation has drifted between two entry points; this is an observed maintenance cost, not just an aesthetic dislike.
Extract one cohesive functionPass required data explicitly and keep semantic defaults/errors identical. Do not silently broaden allowed inputs during a refactor.
Run characterization/regression testsCompare old/new outputs and failures on boundary inputs; separately propose intentional behavior changes with their own tests.
Check your understanding: Does a long function always need splitting?

No. Length is a signal, not proof. Cohesion, complexity, responsibilities, and change frequency determine whether extraction helps.

Run and change the example

Download guide_labs.py. Python 3.10+, standard library only. In its directory, run:

python3 guide_labs.py refactoring
python3 guide_labs.py test

The lab compares two implementations over valid and invalid inputs, including exception behavior.

The program tests its stated finite contracts. It is an educational model, not an implementation of the named production product or a proof of unmodeled distributed faults.

Sources and further verification

Summary

IdeaThe one-line takeaway
What a smell isA surface symptom of a deeper design problem — a hint, not a bug.
Heuristics, not rulesNotice the smell, then judge whether it actually hurts before you fix it.
Pattern 1 · BloatersCode grown too big — extract methods, classes, and parameter objects.
Pattern 2 · OO abusersMisused inheritance and conditionals — reach for polymorphism.
Pattern 3 · Change preventersMake change cheap by giving each reason-to-change one home.
Pattern 4 · DispensablesDelete what adds no value; duplication and dead code first.
Pattern 5 · CouplersMove behavior to the data it uses; hide long navigation chains.
Using smells wellTests protect, small steps, Boy Scout rule, review for smells.
The recurring theme: smells are a shared vocabulary for “this will be painful to change later.” They earn their keep only when paired with tests and small, disciplined refactorings — the point is always cheaper change, never cleaner code for its own sake.

⌂