Halloween DealsAmazon USHalloween week deals: useful picks before month-endAmazon US: current deals, useful picks and tech finds.Check DealsClean PCRecommendedOne scan can reveal what keeps slowing WindowsLook for cleanup and repair opportunities.Run ScanHalloween DealsAmazon USOctober picks are moving - compare before checkoutAmazon US: current deals, useful picks and tech finds.See Picks×
FREEDOM251

Freedom251 › Blog › Dataclasses

How Can Python OOP Keep Classes Clear and Maintainable?

By Freedom251 Team · · 11 min read

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The best Python OOP code gives every object a clear responsibility, keeps ownership of state explicit, and chooses the simplest relationship that expresses the design. Use a class when data and behavior belong together; use a dataclass when a type is mainly named data; use delegation when one object uses another service; and reserve inheritance for a genuine substitutable relationship.

These choices matter more than how many classes a program contains. The sections below show how to make them deliberately, with concrete Python patterns and the failure modes that make object-oriented code difficult to maintain.

As an Amazon Associate I earn from qualifying purchases.

Start with responsibility, not with a class hierarchy

The Python tutorial describes the purpose of a class succinctly: “Classes provide a means of bundling data and functionality together.” That does not mean every function or data structure needs a class. A class earns its place when an object owns state, enforces an invariant, provides behavior over that state, or represents a concept that appears repeatedly in the program.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Before writing class, answer three questions:

  • What state does this object own, and is that state unique to one instance?
  • What operations are naturally responsible for changing or inspecting that state?
  • What relationship does this object have with other objects: specialization, use, or merely a shared interface?

If the answers are unclear, a function, a module-level constant, or a simple collection may be clearer. Object-oriented design is a tool, not a requirement for every Python problem.

Make state ownership explicit

Python gives you both instance attributes and class attributes. An instance attribute belongs to one object; a class attribute is looked up on the class and is shared by instances unless an instance assignment shadows it. That distinction should be visible in the class design.

Put per-instance data in the initializer

Values that can differ between objects belong on self, normally initialized in __init__. This includes mutable containers such as lists, dictionaries, and sets.

class ShoppingCart:
    def __init__(self, owner):
        self.owner = owner
        self.items = []

    def add(self, item):
        self.items.append(item)

Each ShoppingCart now receives its own list. A caller can create two carts without one cart’s additions appearing in the other.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use class attributes only for intentional shared values

A class attribute is appropriate for a constant or a value deliberately shared by all instances, such as a default tax rate that is changed centrally. It is not an appropriate default location for per-object mutable state.

The Python tutorial illustrates the trap with a Dog.tricks list: two dogs append to the same list when tricks is declared on the class. Moving tricks = [] into __init__ gives each dog a separate collection. See the tutorial’s class examples at docs.python.org/3/tutorial/classes.html.

class Dog:
    tricks = []          # shared by every dog: usually a bug

    def __init__(self, name):
        self.name = name

# Safer:
class Dog:
    def __init__(self, name):
        self.name = name
        self.tricks = []

    def learn(self, trick):
        self.tricks.append(trick)

Decide whether state belongs outside the object

Some values are request-scoped, temporary, or derived from a database and do not belong as long-lived attributes. Keeping them in a function or a separate service can prevent objects from becoming implicit global state. A useful test is ownership: if an object cannot explain why it stores a value or how it keeps that value valid, the value probably belongs elsewhere.

Put behavior beside the state it protects

Methods are most useful when they express operations on the object’s own data and preserve its invariants. Instead of exposing several fields and asking every caller to update them in the right order, provide one operation that performs the change safely.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
class BankAccount:
    def __init__(self, balance=0):
        if balance < 0:
            raise ValueError("balance cannot be negative")
        self._balance = balance

    @property
    def balance(self):
        return self._balance

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

The caller does not need to know how the balance is stored or which checks precede a withdrawal. This is encapsulation in a practical sense: the object owns the rule and the transition.

Keep the public surface intentional

Give public methods and attributes names that describe stable operations. Prefix implementation details with a single underscore when they are not part of the intended external interface, and avoid exposing mutable internals when callers could accidentally violate invariants. Return a copy or an immutable view when that is necessary to keep ownership clear.

Do not create a method solely to mirror an attribute. A property is useful when reading a value requires computation, validation, or a compatibility layer; otherwise a plain attribute is often easier to understand.

Choose a dataclass for data-shaped types

When a type mainly bundles a few named data items, a dataclass is Python’s idiomatic option. It removes repetitive constructor and representation code while keeping the fields explicit. The tutorial identifies dataclasses as the natural approach for a record-like structure similar to a Pascal record or C struct.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

Use a regular class instead when the type has substantial behavior, carefully controlled construction, a complex invariant, or an API that should not expose all of its fields as record-like data.

Design need Usually the clearest choice Reason
Named values with little behavior Dataclass Communicates the data shape and avoids ceremony.
State transitions and invariants Regular class with methods Operations can validate and control changes.
A service used by another object Delegation The using object stays focused while the service owns its work.
A true specialized form of an existing type Inheritance Overrides and shared behavior express a substitutable relationship.
Interchangeable behavior without a nominal hierarchy Duck typing or an ABC The contract can stay informal or become explicit as the design requires.

Prefer delegation when an object uses another object

Delegation is an object-oriented technique in which one object forwards a responsibility to another object that is better suited to perform it. The Python Programming FAQ calls delegation “an object-oriented technique (also called a design pattern).”

class EmailSender:
    def send(self, address, message):
        # transport details live here
        print(f"Sending to {address}: {message}")

class Registration:
    def __init__(self, sender):
        self.sender = sender

    def confirm(self, user):
        self.sender.send(user.email, "Your registration is complete")

Registration owns registration policy; EmailSender owns delivery. The dependency can be replaced with a test double or another sender without making Registration inherit from a transport class.

Do not replace every type check mechanically

Repeated branches such as “if this is type A, do one thing; if it is type B, do another” can signal that behavior belongs on the objects themselves or in a delegated service. Moving the operation to the relevant object often makes extension easier. This is guidance, not an absolute ban: a type check is reasonable at a boundary where the program must reject unsupported input, select a serialization format, or handle genuinely different external protocols.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use inheritance only for a meaningful specialization

Inheritance is appropriate when the derived object is a specialized form of the base object and callers can use it wherever the base is expected. It is not a good reason to inherit merely to reuse a few lines of implementation.

class PaymentMethod:
    def authorize(self, amount):
        raise NotImplementedError

class CardPayment(PaymentMethod):
    def authorize(self, amount):
        return self._contact_gateway(amount)

    def _contact_gateway(self, amount):
        return True

Here, a card payment is a payment method and supplies the same authorization operation. If the relationship is instead “a checkout process uses a gateway,” composition and delegation describe it more accurately.

Override deliberately

An override should preserve the base method’s expected contract: accepted inputs, returned results, and important exceptions. If a subclass must disable most of the base behavior, the hierarchy may be expressing the wrong relationship. Keep overridden methods small and document any changed guarantees.

Understand method resolution order

Python supports multiple inheritance. In a diamond-shaped hierarchy, method lookup follows a defined method resolution order (MRO), and cooperative calls to super() allow each class in that order to participate. The order is dynamic and designed to handle diamonds, so the behavior is predictable but not trivial.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
class Logged:
    def save(self):
        print("log")
        super().save()

class Stored:
    def save(self):
        print("store")

class LoggedStored(Logged, Stored):
    pass

LoggedStored().save()  # log, then store

Use multiple inheritance when the combination is a deliberate part of the design, especially for small mixins with cooperative methods. Every participating method should call super() with compatible arguments. If readers must trace a tangled graph to know which implementation runs, delegation is usually easier to maintain.

Choose duck typing or an ABC according to the contract

Python supports duck typing: a consumer can accept any object that supplies the operations it needs, without requiring inheritance from a particular class. This keeps APIs flexible and is often enough for a small internal boundary.

def write_report(writer, text):
    writer.write(text)

write_report needs a write method; a file-like object, an in-memory buffer, or a test double can provide it. The consumer depends on behavior rather than a nominal type.

Make an explicit contract with abc

An abstract base class (ABC) is useful when the contract should be documented and checked as part of the type design, or when several implementations share behavior.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
from abc import ABC, abstractmethod

class Cache(ABC):
    @abstractmethod
    def get(self, key):
        """Return a value or None when the key is absent."""

    @abstractmethod
    def put(self, key, value):
        """Store a value under key."""

The abc documentation provides the infrastructure for ABCs. Use it when an explicit nominal contract adds value, not as a ceremonial base class for every protocol.

Know what collections.abc recognizes

The collections.abc documentation describes several ways an object can satisfy an interface: direct inheritance, virtual subclass registration, and recognition of some simple method-based interfaces. Complex semantics cannot always be inferred merely from method names. If an interface requires ordering guarantees, side effects, or coordinated methods, document and test those rules explicitly.

Keep equality and hashing consistent

Decide what it means for two instances to be equal before implementing __eq__. If equality represents value identity, compare the fields that define that value; if it represents object identity, the default behavior may already be correct.

class UserId:
    def __init__(self, value):
        self.value = value

    def __eq__(self, other):
        if not isinstance(other, UserId):
            return NotImplemented
        return self.value == other.value

Hashing must match equality. The language reference warns that when a class defines __eq__() but not __hash__(), its instances cannot be used in hashable collections such as sets or dictionary keys. Read the complete rule in the Python data model reference.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Choose a safe value policy

  • If instances are mutable and equality can change over time, leave them unhashable rather than allowing a key to move between hash buckets.
  • If instances are immutable value objects, implement equality and a matching hash over the same fields.
  • If identity is the meaning, do not add value-based equality just to make tests convenient.

Test the contract directly: equal objects should compare equal, and any objects used as dictionary keys should remain stable for as long as they are stored.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

A practical design workflow

  1. Name the responsibility. Write one sentence describing what the object owns and does. If the sentence contains several unrelated verbs, split the design.
  2. List the state. Mark each value as per-instance, intentionally shared, derived, temporary, or external. Initialize per-instance mutable values in __init__.
  3. Protect invariants. Put validation and state transitions in methods on the object that owns the rule.
  4. Classify relationships. Use inheritance for a meaningful specialization; use delegation when one object consumes another object’s service.
  5. Set the contract. Start with duck typing when only a few operations are needed. Introduce an ABC when a documented nominal contract or shared implementation is valuable.
  6. Select the data representation. Use a dataclass for a primarily record-like type; use a regular class when behavior and invariants dominate.
  7. Review equality. Decide whether instances are identity objects or value objects, then align __eq__ and __hash__.
  8. Trace extension. For multiple inheritance, inspect the MRO and verify cooperative super() calls. If the path is difficult to explain, simplify it.

Common failure modes and their fixes

Every instance unexpectedly shares a list

Symptom: adding an item to one object changes another object. Cause: a mutable list or dictionary was defined as a class attribute. Fix: create it in __init__, as in the Dog example above.

A subclass is full of exceptions to the base class

Symptom: overridden methods reject inputs the base accepted, return unrelated types, or disable core behavior. Cause: inheritance was chosen for code reuse rather than specialization. Fix: extract a smaller shared contract, use delegation, or make the classes independent.

Callers contain long type-switch chains

Symptom: every new implementation requires editing several isinstance branches. Cause: behavior is centralized in the consumer instead of supplied by the objects or a delegated strategy. Fix: move the operation behind a common method when the semantics are genuinely shared. Retain explicit checks where they are part of input validation or protocol handling.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

An object cannot be used in a set after adding equality

Symptom: inserting an instance into a set raises a “unhashable type” error. Cause: __eq__ was defined without a compatible __hash__. Fix: keep the object unhashable if it is mutable, or implement a stable hash over the same immutable value fields used for equality.

Multiple inheritance calls methods twice or not at all

Symptom: initialization or another operation is skipped or duplicated. Cause: classes in the MRO do not cooperate consistently with super(), or signatures are incompatible. Fix: inspect Class.__mro__, make cooperative methods call super() exactly once, and keep their signatures compatible. If that discipline is not worth the complexity, delegate instead.

Putting the choices together

The following small design separates data, policy, and an external service without forcing them into one hierarchy.

from dataclasses import dataclass

@dataclass(frozen=True)
class Money:
    amount: int
    currency: str

class PaymentGateway:
    def charge(self, money):
        raise NotImplementedError

class Checkout:
    def __init__(self, gateway):
        self.gateway = gateway
        self._total = Money(0, "USD")

    @property
    def total(self):
        return self._total

    def add(self, price):
        if price.currency != self._total.currency:
            raise ValueError("currency mismatch")
        self._total = Money(
            self._total.amount + price.amount,
            self._total.currency,
        )

    def pay(self):
        return self.gateway.charge(self._total)

Money is a value-shaped type, so a dataclass communicates its fields and value semantics. Checkout owns the total and its currency invariant. The gateway is delegated the external charge operation. A concrete gateway can supply the required charge behavior without inheriting from Checkout; an ABC could be introduced later if the gateway contract needs to be formalized for many implementations.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Final review checklist

  • Can you state each class’s responsibility in one sentence?
  • Does every mutable value belong to the object that owns it?
  • Are class attributes intentionally shared rather than accidental shared state?
  • Do methods enforce the invariants of their own data?
  • Is inheritance expressing a real specialization rather than convenient reuse?
  • Would delegation make a service relationship clearer?
  • Is duck typing sufficient, or does an ABC provide a useful explicit contract?
  • Is a dataclass clearer because the type is primarily named data?
  • Do equality and hashing describe the same notion of identity?
  • Can another developer explain the MRO and cooperative super() calls without guesswork?

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

DataclassesDuck Typinginheritanceobject-oriented programmingPythonPython Classessoftware design

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Recommended PC Tool
Recommended PC Tool
PC Slower Than It Used to Be?Free scan - under a minute
Crashes, No Sound, or Screen Glitches?Free driver scan

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.