Python makes classes easy. We make most of them dataclasses, most contracts Protocols, and most "inheritance for reuse" something else.
from dataclasses import dataclass
from enum import StrEnum
from typing import Literal, Protocol, assert_never
class Currency(StrEnum):
USD = "USD"
EUR = "EUR"
@dataclass(frozen=True, slots=True)
class Money:
minor_units: int
currency: Currency
def __post_init__(self) -> None:
if self.minor_units < 0:
raise ValueError("Money must be non-negative")
@dataclass(frozen=True, slots=True)
class Approved:
receipt_id: str
kind: Literal["approved"] = "approved"
@dataclass(frozen=True, slots=True)
class Declined:
reason: str
kind: Literal["declined"] = "declined"
ChargeResult = Approved | Declined
class PaymentGateway(Protocol):
async def charge(self, amount: Money) -> ChargeResult: ...
async def settle(gateway: PaymentGateway, amount: Money) -> str:
result = await gateway.charge(amount)
match result:
case Approved(receipt_id=rid): return rid
case Declined(reason=why): raise RuntimeError(why)
case _: assert_never(result)Money is a frozen=True, slots=True value type validating its invariant in __post_init__ (6.1, 6.6); the non-defaulted fields precede the defaulted kind discriminator in every variant; Currency is a StrEnum closed set (6.2); ChargeResult is a Literal-discriminated union narrowed by an exhaustive match with assert_never (6.4); PaymentGateway is a Protocol, so settle depends on a shape, not a base class (6.3, 6.5).
Reasoning, step by step:
- A "value-shaped" type is one whose identity is its content: a
Point(x, y), aUserId, aPaymentRequest. Two with the same fields are equal. @dataclassgives you__init__,__repr__,__eq__(and__hash__iffrozen=True) for free. Writing these by hand is wasted typing and a future bug.- Always
frozen=Truefor value types. An equal-but-mutable record breaks__hash__stability — put one in asetand mutate it, and the set is broken. - Always
slots=True(3.10+) for hot-path value types — saves memory and speeds attribute access. Cost: no dynamic attribute assignment, which for value types is a feature. - Don't add behavior beyond simple derived properties. Dataclasses are records, not service objects.
- Pattern:
@dataclass(frozen=True, slots=True) class UserId: raw: str def __post_init__(self) -> None: if not self.raw: raise ValueError("UserId must be non-empty")
Enforcement: review; value-shaped types carry frozen=True, slots=True, no hand-written __init__/__eq__/__hash__.
Reasoning, step by step:
- When a value can only be one of a finite set (
OrderStatus,Currency,LogLevel), make it an enum. enum.StrEnum(3.11+) gives string-valued enums where the value is the wire format:Currency.USD == "USD". Useful at JSON/serialization boundaries.enum.IntEnumfor integer-valued sets (HTTP status codes, OS signal numbers).- Plain
enum.Enumfor sets where values are arbitrary tokens. - Anti-pattern: stringly-typed constants (
STATUS_ACTIVE = "active"). Use an enum. mypy + IDE completion both win.
Enforcement: mypy rejects an out-of-set literal where the enum is expected; review flags stringly-typed constant clusters.
Reasoning, step by step:
Protocolis structural typing — any class with the right methods is accepted. No inheritance needed.- ABC requires subclassing. Subclasses declare the relationship.
- Choose
Protocolwhen: you want dependency-inversion (the caller provides any object with the right shape) — most "interface" use cases. - Choose ABC when: you need shared implementation in the base, or you need
isinstancechecks to work at runtime (use@runtime_checkableon a Protocol if you only need that). - From the broader principle: prefer composition over inheritance. Inheritance is reserved for genuine "is-a" relationships and shared implementation (chapter 06 §6.4 of the Kotlin guide makes the same case).
Enforcement: review; Protocol for dependency-inversion seams, ABC reserved for shared implementation or isinstance needs.
Reasoning, step by step:
- Python doesn't have native sealed classes. We approximate with:
- Discriminated unions:
@dataclass(frozen=True, slots=True) class Approved: receipt: Receipt kind: Literal["approved"] = "approved" @dataclass(frozen=True, slots=True) class Declined: reason: str kind: Literal["declined"] = "declined" ChargeResult = Approved | Declined
- mypy narrows on
match result.kind:orif isinstance(result, Approved):.
- Discriminated unions:
- Exhaustive
match: useassert_neverfromtypingfor the unreachable case:Adding a new variant fails the type check until you add a case.from typing import assert_never match result: case Approved(receipt=r): handle(r) case Declined(reason=why): log(why) case _: assert_never(result)
- For a fixed set of behaviors (not just data), an ABC with two subclasses works too. Pick by whether the variants share methods or just shapes.
Enforcement: mypy's assert_never fails the type check when a new variant is added without a match case.
Reasoning, step by step:
- Inheritance couples lifecycle. A subclass is a parent — any change to the parent affects every subclass.
- The 1990s "Animal → Dog → Poodle" hierarchy is almost always wrong in real code. Real systems have orthogonal axes that don't form a tree.
- Composition: take the dependency as a constructor arg, expose what you need on your own surface.
- Acceptable inheritance: (a) extending stdlib/framework classes when forced (
Exception,Enum, ABCs you're implementing), (b) sealed-style hierarchies where the variants share the parent as a tag, (c) genuinely shared implementation with a single subclass-author audience. - Anti-pattern:
class UserService(BaseService): ...whereBaseServiceexists only because three services happened to need a logger. Inject the logger.
Enforcement: review; reject Base*/Abstract* parents that exist only to share a dependency rather than model an "is-a".
Reasoning, step by step:
__init__(or__post_init__for dataclasses) should: (a) store the parameters, (b) validate invariants, (c) raise on violation. Nothing else.- No I/O in constructors. No "convenience" file reads, no network calls. Construction must be cheap and predictable.
- For complex creation: use a classmethod factory (
User.from_dict(...),Config.load(...)). Document side effects in the factory's docstring. - Pattern: dataclasses with
__post_init__for validation. Regular classes with__init__for everything else.
Enforcement: review; no I/O in __init__/__post_init__, invariants validated and raised at construction.
Reasoning, step by step:
@classmethodreceives the class ascls. Use for: alternative constructors (from_json,from_dict,default), factory methods that need to know the subclass.@staticmethodreceives nothing. Use for: utility functions that belong to the class namespace but don't needselforcls. Honestly, this is rare — usually a module-level function is better.- Anti-pattern:
@staticmethodfor what should be a top-level function. The class wrapping adds nothing.
Enforcement: review; @classmethod only where cls is used, @staticmethod only where the class namespace earns its place over a module function.
Reasoning, step by step:
- Python's MRO (method resolution order) is C3 linearization. It's correct, but tracing it through five base classes is a debugging nightmare.
- Mixins (small, focused classes that add one capability) are acceptable:
class User(LoggingMixin, JsonMixin, SerializableMixin): .... Keep mixins small and orthogonal. - Diamond inheritance (two parents share a grandparent) usually means you should be composing instead.
- Lint: detekt-equivalent doesn't exist for this in Python tooling, but the rule stands. Code review catches it.
Enforcement: review; multiple inheritance limited to small orthogonal mixins, diamonds rejected in favor of composition.
Reasoning, step by step:
- Pythonic classes participate in language protocols via dunders:
__eq__,__hash__,__iter__,__contains__,__len__,__getitem__,__enter__/__exit__(context manager),__call__. - Implement them when your class is that thing.
__iter__if your class is iterable.__len__if it has a meaningful length. - Anti-pattern: implementing
__add__because "it might be useful." Operator overloading is for types where the operation reads naturally —Vector + Vector,Duration + Duration. NotUser + Order. - Keep dunders consistent: if you implement
__eq__, you almost always need__hash__. Mismatch breaks Python's contracts (e.g., set membership).
Enforcement: review; a dunder appears only when the class genuinely is that protocol, and __eq__/__hash__ stay paired.
Reasoning, step by step:
@dataclass(frozen=True, slots=True)— default choice. Mutable or immutable, fast, supports methods, integrates with type system.NamedTuple— when the value should also be a tuple (positional access, iterable, indexable). Common for return values with positional meaning:Coordinate(x, y). Slightly cheaper than dataclass for pure-data use.TypedDict— when the value is a dict (JSON shape, kwargs bag, config). No methods, no constructor logic.- Pick by the consumer's needs: do they index, unpack, mutate, or pattern-match? Each implies a different choice.
Enforcement: review; the carrier matches the consumer — dataclass by default, NamedTuple for positional/tuple use, TypedDict for dict shapes.
Reasoning, step by step:
- If a value is derived (
user.full_namefromfirst + last), make it a@propertyor a@cached_property. - If a value is stored (
user.email), it's a regular attribute. - Don't fake encapsulation with getter/setter methods. Python has properties for a reason.
- Don't add a property "in case we need it later." YAGNI — and the moment you do need it, change the attribute to a property; callers don't see the difference.
Enforcement: review; derived state is a @property/@cached_property, stored state is a plain attribute, no getter/setter method pairs.
Reasoning, step by step:
__eq__without__hash__means the class isn't hashable (can't be a dict key or set element). Sometimes correct, often a bug.@dataclass(frozen=True)generates a consistent__eq__+__hash__. Use it.@dataclass(eq=False)falls back to identity equality (is). Useful for entities where two records with the same fields aren't equal because they have different identities (databases, sessions).- The Python rule: equal objects must have equal hashes. Breaking this corrupts dicts and sets — silently. Use
frozen=Trueand forget about it.
Enforcement: review; equality and hashing come from frozen=True or are deliberately absent (eq=False for identity), never inconsistently hand-rolled.
Reasoning, step by step:
-
When a class can be constructed from multiple sources (a URL, a connection string, a dict, a parsed JSON blob), don't overload
__init__with sentinel arguments andif isinstance(...)branches. -
Use classmethod factories. Each takes one input type, validates it, and delegates to the canonical
__init__. Reads as a sentence at the call site:Client.from_connection_string(s),Client.from_url(u, credential),User.from_dict(payload). -
Pattern (service client — regular class, not a dataclass):
from typing import Self class PaymentClient: def __init__(self, endpoint: str, credential: TokenCredential, *, timeout: float = 30.0) -> None: self._endpoint = endpoint self._credential = credential self._timeout = timeout @classmethod def from_connection_string(cls, conn_str: str, *, timeout: float = 30.0) -> Self: endpoint, credential = _parse_conn_str(conn_str) return cls(endpoint, credential, timeout=timeout) @classmethod def from_environment(cls, *, timeout: float = 30.0) -> Self: return cls.from_connection_string(os.environ["PAYMENTS_CONN_STR"], timeout=timeout)
Note: a service client is not a frozen dataclass — it owns runtime state (the credential may rotate, the transport pool tracks connections). Use a regular class with
__init__. Value-shaped types are the ones that get@dataclass(frozen=True, slots=True)(§6.1). -
Pattern (value type — dataclass):
from typing import Self @dataclass(frozen=True, slots=True) class User: id: UserId email: Email @classmethod def from_dict(cls, payload: Mapping[str, Any]) -> Self: return cls(id=UserId(payload["id"]), email=Email(payload["email"]))
-
The canonical
__init__(or__post_init__for dataclasses) is the one path that validates invariants. Factories construct inputs and delegate. -
Factory methods may do small I/O (env lookup, parsing). Document side effects in the factory's docstring; keep them limited and predictable.
-
Anti-pattern:
def __init__(self, endpoint=None, conn_str=None, url=None): ... if conn_str: ...; elif url: .... The signature is a lie and mypy can't help. -
Return type
Self(Python 3.11+) — subclass factories return the subclass naturally.
Enforcement: review; multi-source construction goes through named @classmethod factories returning Self, not an __init__ with optional sentinel arguments.
from dataclasses import dataclass
from enum import StrEnum
from typing import Literal, Protocol, assert_never
class Currency(StrEnum):
USD = "USD"
EUR = "EUR"
GBP = "GBP"
@dataclass(frozen=True, slots=True)
class UserId:
raw: str
def __post_init__(self) -> None:
if not self.raw:
raise ValueError("UserId must be non-empty")
@dataclass(frozen=True, slots=True)
class Approved:
receipt_id: str
kind: Literal["approved"] = "approved"
@dataclass(frozen=True, slots=True)
class Declined:
reason: str
kind: Literal["declined"] = "declined"
ChargeResult = Approved | Declined
class PaymentGateway(Protocol):
async def charge(self, user_id: UserId, amount: int, currency: Currency) -> ChargeResult: ...
async def settle(gateway: PaymentGateway, user_id: UserId, amount: int) -> None:
result = await gateway.charge(user_id, amount, Currency.USD)
match result:
case Approved(receipt_id=rid): logger.info("approved %s", rid)
case Declined(reason=why): logger.warning("declined: %s", why)
case _: assert_never(result)
# bad
class AbstractPayment: # 6.5 — open inheritance not needed
pass
class Payment(AbstractPayment):
def __init__(self):
self.id = "" # 6.1 — should be a frozen dataclass
self.amount = 0Protocoland structural typing: chapter 03.- Pattern matching as a refactor-safety tool: chapter 07.
- Sealed/discriminated unions in error handling: chapter 08.
- Client constructor signature and immutability: chapter 10.