---
title: "When to Use Classes vs. Functions in Python: A Design Checklist"
description: "A repeatable mental checklist for Python developers to decide when to use classes, functions, instance state, and dependency injection."
pubDate: "2023-05-25"
lang: "en"
slug: "when-to-use-classes-vs-functions-in-python"
url: "https://hoangyell.com/when-to-use-classes-vs-functions-in-python/"
author: "HoangYell"
translation_vi: "https://hoangyell.com/vi/when-to-use-classes-vs-functions-in-python/"
categories: ["Software Craft"]
tags: ["python", "software-design", "architecture"]
heroImage: "https://raw.githubusercontent.com/HoangGeek/store/main/webp/photo-1649180556628-9ba704115795.webp"
---

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

```python
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

```python
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`

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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
