Classes, Dataclasses & Protocols
Modeling with classes without over-engineering: dataclasses, composition over inheritance, and structural typing via Protocol.
A class is not the default answer in Python. Many problems are better as a function plus a few dictionaries or dataclasses. Reach for a class when you need to keep state and behavior together, enforce invariants, or expose a stable interface that several implementations can satisfy.
Good Python OOP is small and boring. Model the domain, compose objects instead of building deep inheritance trees, and use protocols when you care about what an object can do rather than what it inherits from.
The mental model
A class defines how to build objects and which operations those objects support. An instance holds state. Methods are functions that receive the instance as self.
class RateLimiter:
def __init__(self, max_per_minute: int):
self.max_per_minute = max_per_minute
self._used = 0
def allow(self) -> bool:
if self._used >= self.max_per_minute:
return False
self._used += 1
return True
The point is not to hide data behind ceremony. The point is to put the rule “only max_per_minute calls are allowed” next to the state that rule protects.
When a class is the right tool
Use a class when at least one of these is true:
- You have state that changes over time and functions that must update it consistently.
- You need to enforce invariants at construction or assignment.
- You have multiple implementations behind one interface.
- You want a value object with clear fields, equality, and representation.
If the function has no memory and no invariant, keep it a function. If the object is just a bag of fields, start with a dataclass.
Dataclasses remove boilerplate
@dataclass generates the obvious parts: __init__, __repr__, and __eq__. Use it for records and value objects.
from dataclasses import dataclass
@dataclass(slots=True, frozen=True)
class Chunk:
text: str
source: str
score: float = 0.0
Two options matter in production:
frozen=Truemakes instances immutable and, when fields are hashable, safely hashable.slots=Trueavoids per-instance__dict__, saving memory when you create many objects and preventing accidental new attributes.
Do not force a dataclass to behave like an active service object. If it opens connections, caches state, or owns lifecycle, write a normal class.
Composition over inheritance
| dict | NamedTuple | dataclass | Pydantic | |
|---|---|---|---|---|
| Fields fixed & named | no | yes | yes | yes |
| Mutable | yes | no | yes (opt frozen) | yes |
| Runtime validation | none | none | none | full |
| Best for | quick/dynamic bags | lightweight records | internal models | external I/O boundaries |
Inheritance says “is a.” Composition says “has a.” Most application code wants composition because it keeps dependencies explicit and testable.
class SearchService:
def __init__(self, embedder, index):
self.embedder = embedder
self.index = index
def search(self, query: str, k: int = 5):
vector = self.embedder.embed(query)
return self.index.nearest(vector, k=k)
SearchService does not need to inherit from an embedding client or a vector index. It coordinates two collaborators. In tests, pass fakes with the same methods.
Class methods, static methods, and properties
Use @classmethod for alternate constructors that need the class. Use @staticmethod rarely, when a helper belongs near a class conceptually but does not need self or cls. Use @property to expose an attribute while protecting an invariant.
class RetryPolicy:
def __init__(self, attempts: int):
self.attempts = attempts
@classmethod
def conservative(cls):
return cls(attempts=2)
@property
def attempts(self) -> int:
return self._attempts
@attempts.setter
def attempts(self, value: int) -> None:
if value < 1:
raise ValueError("attempts must be positive")
self._attempts = value
A property is not a way to hide arbitrary work. Keep it cheap and unsurprising; expensive operations should be methods.
Protocols: structural typing
An abstract base class asks an object to inherit from a specific parent. A Protocol asks an object to have a specific shape.
from typing import Protocol
class Embedder(Protocol):
def embed(self, text: str) -> list[float]: ...
def embed_all(model: Embedder, texts: list[str]) -> list[list[float]]:
return [model.embed(text) for text in texts]
Any object with a compatible embed method satisfies Embedder; no registration or inheritance required. That matches Python’s duck-typing culture while giving a static checker something precise to verify.
When to reach for this
Variations
Try it
These exercises are the shapes you use in application code: turn loose data into explicit objects, type a duck-typed dependency, and protect an invariant.
Convert dicts into dataclasses
You receive user data as a dictionary of dictionaries. Convert it into explicit objects so downstream code no longer reaches through string keys everywhere.
Approach. Model nested records as dataclasses and put the conversion at the boundary. Use tuples for immutable collections.
Show solution
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Plan:
name: str
max_projects: int
@dataclass(frozen=True, slots=True)
class User:
id: int
email: str
plan: Plan
tags: tuple[str, ...] = ()
@classmethod
def from_mapping(cls, raw: dict) -> "User":
plan = Plan(
name=raw["plan"]["name"],
max_projects=raw["plan"]["max_projects"],
)
return cls(
id=raw["id"],
email=raw["email"],
plan=plan,
tags=tuple(raw.get("tags", ())),
)
raw_user = {
"id": 7,
"email": "ada@example.com",
"plan": {"name": "pro", "max_projects": 20},
"tags": ["founder", "ml"],
}
user = User.from_mapping(raw_user)The rest of the program now says user.plan.max_projects, not raw["plan"]["max_projects"]. The parsing risk is isolated at the boundary.
Type a duck-typed embedder
Write a function that ranks documents by dot product with a query embedding. It should accept any object with an embed(text) -> list[float] method, not a specific base class.
Approach. Define a Protocol for the behavior you need. The ranking function uses only that behavior, so real and fake embedders both work.
Show solution
from typing import Protocol
class Embedder(Protocol):
def embed(self, text: str) -> list[float]: ...
def dot(a: list[float], b: list[float]) -> float:
return sum(x * y for x, y in zip(a, b))
def rank_documents(query: str, docs: list[str], model: Embedder) -> list[str]:
query_vec = model.embed(query)
return sorted(docs, key=lambda doc: dot(query_vec, model.embed(doc)), reverse=True)
class FakeEmbedder:
def embed(self, text: str) -> list[float]:
return [float(len(text)), float(text.count("ai"))]
ranked = rank_documents("ai systems", ["ai", "databases"], FakeEmbedder())FakeEmbedder never inherits from Embedder; it simply has the right method. That keeps tests lightweight while still giving static type checkers a contract.
Protect a property with validation
Create a Temperature class whose Celsius value cannot go below absolute zero. Callers should read and assign temp.celsius like an attribute.
Approach. Store the actual value in a private attribute and route assignments through a property setter that enforces the invariant.
Show solution
class Temperature:
ABSOLUTE_ZERO_C = -273.15
def __init__(self, celsius: float):
self.celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < self.ABSOLUTE_ZERO_C:
raise ValueError("temperature cannot be below absolute zero")
self._celsius = value
@property
def fahrenheit(self) -> float:
return self.celsius * 9 / 5 + 32
temp = Temperature(20)
temp.celsius = 25
assert temp.fahrenheit == 77The caller sees attribute syntax, but the object never enters an invalid state. Keep property work cheap; validation and derived values are good fits.