Featured image of post When to Use Classes vs. Functions in Python: A Design Checklist

When to Use Classes vs. Functions in Python: A Design Checklist

A repeatable mental checklist for Python developers to decide when to use classes, functions, instance state, and dependency injection.

You’re coding, momentum is good, then the design questions hit:

  • Should this logic be a method or a standalone function?
  • Do I put this on self or keep it local?
  • Should I inject this dependency or just create it inside the class?
  • What is this class actually responsible for?
  • If I add one more if/else, am I being practical… or building a blob?

This post gives you a repeatable mental checklist with small Python examples you can copy-paste.


A simple rule of thumb you can reuse

Before we go into the 5 questions, keep this framing:

  • Function = “Given inputs → return output.” No hidden state.
  • Class = “An object with a job.” It holds invariants, state, and dependencies needed to do that job consistently.
  • self.attribute = “This must live longer than one call” or “Other methods need it.”
  • Dependency injection = “I want to swap this thing (tests, environments, versions).”
  • New class/module = “This is a different job.”

1) Should this logic live inside the class or outside as a function?

Use a method when the logic:

  • needs the object’s state (self.*)
  • must preserve/validate invariants
  • is part of the object’s public API

Use a module-level function when the logic:

  • is pure or mostly pure (inputs → output)
  • is useful across different classes
  • doesn’t need private state

Example: keep pure math outside

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from dataclasses import dataclass


def apply_discount(price: float, percent: float) -> float:
    if percent < 0 or percent > 100:
        raise ValueError("percent must be 0..100")
    return price * (1 - percent / 100)


@dataclass
class CartItem:
    name: str
    price: float

    def discounted_price(self, percent: float) -> float:
        # method is just a convenience wrapper around pure logic
        return apply_discount(self.price, percent)

Why this is nice:

  • apply_discount is reusable anywhere.
  • CartItem.discounted_price() reads well and stays thin.

Example: put logic in the class when it protects invariants

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class BankAccount:
    def __init__(self, balance: int = 0):
        if balance < 0:
            raise ValueError("balance must be >= 0")
        self._balance = balance

    def withdraw(self, amount: int) -> None:
        if amount <= 0:
            raise ValueError("amount must be > 0")
        if amount > self._balance:
            raise ValueError("insufficient funds")
        self._balance -= amount

This belongs in the class because the class is responsible for keeping _balance valid.

Quick test: If you removed the class and wrote withdraw(balance, amount) -> new_balance, would you lose important rules about the object? If yes, method.


2) When should I store something as self.attribute versus keep it local?

Put it on self when:

  • it’s needed across multiple method calls
  • it’s needed by multiple methods
  • it represents configuration, dependency, or cache
  • it helps enforce an invariant

Keep it local when:

  • it’s temporary computation
  • it’s derivable from other state
  • storing it would create state you must keep in sync

Example: configuration belongs on self

1
2
3
4
5
6
7
8
class CsvExporter:
    def __init__(self, delimiter: str = ","):
        self.delimiter = delimiter  # configuration: long-lived

    def export_row(self, values: list[str]) -> str:
        # temporary variable: local
        escaped = [v.replace('"', '""') for v in values]
        return self.delimiter.join(f'"{v}"' for v in escaped)

Example: don’t store derived values unless you must

1
2
3
4
5
6
7
8
class Rectangle:
    def __init__(self, w: float, h: float):
        self.w = w
        self.h = h

    def area(self) -> float:
        # derived from state; keep local
        return self.w * self.h

If you stored self.area = self.w * self.h, you’d now need to keep it updated when w or h changes.

Example: caching is a valid reason to store

1
2
3
4
5
6
7
8
9
class UserProfileService:
    def __init__(self, db):
        self.db = db
        self._cache: dict[int, dict] = {}

    def get_user(self, user_id: int) -> dict:
        if user_id not in self._cache:
            self._cache[user_id] = self.db.fetch_user(user_id)
        return self._cache[user_id]

3) When should I inject a dependency instead of creating it inside the class?

Inject when:

  • you want easy testing
  • you want different implementations (prod vs dev)
  • the dependency is expensive or shared
  • you want to avoid hard-coded reality inside your class

Create internally when:

  • it’s a small, stable detail
  • it’s unlikely to be replaced
  • you don’t need to fake it in tests

Bad for testability: dependency created inside

1
2
3
4
5
6
7
import requests

class WeatherClient:
    def get_temp(self, city: str) -> float:
        resp = requests.get(f"https://example.com/weather?city={city}")
        resp.raise_for_status()
        return resp.json()["temp"]

Better: inject the HTTP client

1
2
3
4
5
6
7
8
class WeatherClient:
    def __init__(self, http_get):
        self.http_get = http_get  # injected dependency

    def get_temp(self, city: str) -> float:
        resp = self.http_get(f"https://example.com/weather?city={city}")
        resp.raise_for_status()
        return resp.json()["temp"]

Even cleaner: inject a gateway object

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class HttpGateway:
    def get_json(self, url: str) -> dict:
        import requests
        r = requests.get(url)
        r.raise_for_status()
        return r.json()


class WeatherClient:
    def __init__(self, http: HttpGateway):
        self.http = http

    def get_temp(self, city: str) -> float:
        data = self.http.get_json(f"https://example.com/weather?city={city}")
        return data["temp"]

4) What exactly should a class be responsible for?

A class should represent one coherent job with:

  • clear inputs/outputs
  • clear invariants
  • a small, understandable API

If you describe the class with “and”, it’s a warning sign.

Example smell: “This class sends emails and formats HTML and retries and logs and reads templates…”

Example: split responsibilities

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class EmailFormatter:
    def format_welcome(self, name: str) -> str:
        return f"Hi {name}, welcome!"


class EmailSender:
    def __init__(self, smtp_client):
        self.smtp_client = smtp_client

    def send(self, to: str, body: str) -> None:
        self.smtp_client.send(to=to, body=body)


class WelcomeEmailService:
    def __init__(self, formatter: EmailFormatter, sender: EmailSender):
        self.formatter = formatter
        self.sender = sender

    def send_welcome(self, to: str, name: str) -> None:
        body = self.formatter.format_welcome(name)
        self.sender.send(to, body)

Each piece has a single reason to change.


5) Add an if/else… or extract something new?

Add the branch when:

  • the variation is small and stable
  • there are only a couple of cases
  • the behavior is clearly part of the same job

Extract when:

  • you’re adding the 3rd or 4th branch and more are coming
  • each branch has meaningful internal complexity
  • you keep editing the same large method
  • you’re passing flags like mode, type, or strategy

Simple if/else is fine

1
2
3
4
5
class PriceCalculator:
    def total(self, base: float, is_member: bool) -> float:
        if is_member:
            return base * 0.9
        return base

Extract strategy when logic grows

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class PricingRule:
    def apply(self, base: float) -> float:
        raise NotImplementedError


class MemberDiscount(PricingRule):
    def apply(self, base: float) -> float:
        return base * 0.9


class BlackFriday(PricingRule):
    def apply(self, base: float) -> float:
        return base * 0.7


class PriceCalculator:
    def __init__(self, rule: PricingRule):
        self.rule = rule

    def total(self, base: float) -> float:
        return self.rule.apply(base)

Now adding a new promotion doesn’t require editing PriceCalculator.


Compact decision cheat-sheet

Method vs function:

  • Needs object state or invariants → method
  • Pure and reusable → function

self.attribute vs local:

  • Used across calls or methods → self
  • Temporary or derived → local

Inject vs create inside:

  • Needs swapping or testing flexibility → inject
  • Truly stable internal detail → create

Add branch vs extract:

  • Two stable cases → simple if/else
  • Growing variation → extract class or strategy
Made with laziness love 🦥

Subscribe to My Newsletter