Large Class code smell: split a class that does too much

A class that stores data, formats text and sends messages is doing three jobs. See how to split a Large Class safely, with tested before and after code.

One overgrown class split into focused classes

A Large Class has too many fields, too many methods, and too many reasons to change. It often starts small. Someone adds "just one more method", then a few more, and the class ends up storing data, formatting reports and sending messages. The code and output below were run with Python 3 before publishing.

How to recognise it

  • Its name is vague (Manager, Helper, Utils, Data) or it keeps growing.
  • It has groups of methods that use different fields. Half of the fields are used by half of the methods.
  • Changing the email text means editing the same file that holds the balance rules.
  • Tests for it need a lot of unrelated setup.
  • You find yourself saying "and" when describing it: "stores the balance and prints statements and sends alerts".

This is the idea behind the single responsibility principle: a class should have one reason to change.

The fix: Extract Class

Find a group of fields and methods that belong together, move them into a new class, and let the original use it (or let callers use both). In the example, the account keeps only money rules. Formatting and messaging move out.

# ---- BEFORE: one class that holds money, formats text and sends messages ----
class AccountBefore:
    def __init__(self, owner):
        self.owner, self.balance, self.history = owner, 0, []
    def deposit(self, amount):
        self.balance += amount; self.history.append(("deposit", amount))
    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance -= amount; self.history.append(("withdraw", amount))
    def statement(self):
        lines = [f"Statement for {self.owner}"] + [f"{kind:<9}{amt:>8}" for kind, amt in self.history]
        return "\n".join(lines + [f"balance  {self.balance:>8}"])
    def to_csv(self):
        return "\n".join(f"{kind},{amt}" for kind, amt in self.history)
    def alert(self, outbox):
        outbox.append(f"To {self.owner}: balance is {self.balance}")

# ---- AFTER: the account keeps money; formatting and messaging live elsewhere ----
class Account:
    def __init__(self, owner):
        self.owner, self.balance, self.history = owner, 0, []
    def deposit(self, amount):
        self.balance += amount; self.history.append(("deposit", amount))
    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance -= amount; self.history.append(("withdraw", amount))

class StatementFormatter:
    def text(self, acct):
        lines = [f"Statement for {acct.owner}"] + [f"{kind:<9}{amt:>8}" for kind, amt in acct.history]
        return "\n".join(lines + [f"balance  {acct.balance:>8}"])
    def csv(self, acct):
        return "\n".join(f"{kind},{amt}" for kind, amt in acct.history)

class Notifier:
    def __init__(self, outbox): self.outbox = outbox
    def balance_alert(self, acct):
        self.outbox.append(f"To {acct.owner}: balance is {acct.balance}")

Account is now small and easy to reason about. StatementFormatter can grow new formats (HTML, PDF) without touching the money rules, and Notifier can be replaced by a fake in tests.

Prove it behaves the same

old, new = AccountBefore("Amina"), Account("Amina")
for a in (old, new):
    a.deposit(500); a.withdraw(120)
box_old, box_new = [], []
f = StatementFormatter()
print(f.text(new))
old.alert(box_old); Notifier(box_new).balance_alert(new)
assert old.statement() == f.text(new) and old.to_csv() == f.csv(new) and box_old == box_new
print(box_new[0])
print("same results, three small classes instead of one big one")
Statement for Amina
deposit       500
withdraw      120
balance       380
To Amina: balance is 380
same results, three small classes instead of one big one

The old text statement, the old CSV and the old alert are identical to the new ones. Behaviour is unchanged, but each job now lives in its own class.

How to refactor safely

  1. Write tests around the current public behaviour first.
  2. Look for the groups of fields that always travel with certain methods.
  3. Create the new class and move one method at a time, running the tests after each move.
  4. Keep the old methods as thin wrappers for a while if many callers depend on them, then remove them.
  5. Name classes by their job (StatementFormatter), not by a vague word (AccountHelper).

When to leave it alone

  • A class with many simple methods that all use the same data can still be cohesive.
  • If splitting would make a reader jump between six files to understand one action, the split is too fine.

Common mistakes

  • Splitting by technical layer only ("all getters here") instead of by responsibility.
  • Making a new Utils class that becomes the next large class.
  • Moving methods without moving the fields they use, so the new class reaches back into the old one.
  • Changing behaviour while moving code. Do one thing at a time.

Main idea: when a class has groups of methods that use different fields, each group wants to be its own class. Extract them one method at a time, keep the tests green, and name each class by its single job.


Code smells and refactoring series, Bloaters: 2 of 5. Previous: Long Method code smell: how to spot it and fix it with Extract Method.

Keep reading