Back to blog
Backend Systemsbeginner

Python Classes & OOP: From Basics to Protocols

Master Python OOP — classes, inheritance, dataclasses, ABCs, Protocols, dunder methods, and the patterns used in real framework and pipeline development.

LearnixoMay 7, 20267 min read
PythonOOPclassesdataclassesprotocolsinheritance
Share:𝕏

Why OOP in Python?

Python lets you write perfectly good code without any classes. But when you're building pipelines, CLI tools, or frameworks, classes give you:

  • Encapsulation — bundle state and logic that belong together
  • Reuse — inheritance and composition avoid repetition
  • Contracts — ABCs and Protocols define what something must support
  • Readabilityuser.deactivate() is clearer than deactivate_user(user)

1. Class Basics

Python
class User:
    def __init__(self, name: str, email: str) -> None:
        self.name = name
        self.email = email
        self.is_active: bool = True

    def deactivate(self) -> None:
        self.is_active = False

    def __repr__(self) -> str:
        return f"User(name={self.name!r}, email={self.email!r})"


user = User("Asma", "asma@example.com")
user.deactivate()
print(user)   # User(name='Asma', email='asma@example.com')

Instance vs class vs static

Python
class Counter:
    total_created: int = 0   # class variable  shared across all instances

    def __init__(self) -> None:
        self.count: int = 0
        Counter.total_created += 1

    def increment(self) -> None:       # instance method  has `self`
        self.count += 1

    @classmethod
    def reset_total(cls) -> None:      # class method  has `cls` (the class itself)
        cls.total_created = 0

    @staticmethod
    def validate_count(n: int) -> bool:  # static method  no implicit arg
        return n >= 0

2. Inheritance

Python
class Animal:
    def __init__(self, name: str) -> None:
        self.name = name

    def speak(self) -> str:
        raise NotImplementedError

class Dog(Animal):
    def speak(self) -> str:
        return f"{self.name} says Woof!"

class Cat(Animal):
    def speak(self) -> str:
        return f"{self.name} says Meow!"

animals: list[Animal] = [Dog("Rex"), Cat("Whiskers")]
for a in animals:
    print(a.speak())

super() — calling the parent

Python
class AdminUser(User):
    def __init__(self, name: str, email: str, role: str) -> None:
        super().__init__(name, email)   # call User.__init__
        self.role = role

    def __repr__(self) -> str:
        base = super().__repr__()
        return f"Admin({base}, role={self.role!r})"

Multiple inheritance and MRO

Python resolves method calls using the Method Resolution Order (MRO) — left to right, then up.

Python
class Logger:
    def log(self, msg: str) -> None:
        print(f"[LOG] {msg}")

class Serializable:
    def to_dict(self) -> dict:
        return self.__dict__

class Pipeline(Logger, Serializable):   # inherits both
    def run(self) -> None:
        self.log("Starting")

p = Pipeline()
p.log("Running")    # from Logger
p.to_dict()         # from Serializable

# inspect MRO
print(Pipeline.__mro__)

3. Properties

Use @property to control attribute access with getter/setter logic.

Python
class Temperature:
    def __init__(self, celsius: float) -> None:
        self._celsius = celsius   # underscore = "private by convention"

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < -273.15:
            raise ValueError("Below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9/5 + 32


t = Temperature(100)
print(t.fahrenheit)   # 212.0
t.celsius = -300      # raises ValueError

4. Dataclasses

@dataclass auto-generates __init__, __repr__, __eq__, and optionally __hash__ and __lt__.

Python
from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class Order:
    id: int
    product: str
    quantity: int
    price: float
    created_at: datetime = field(default_factory=datetime.utcnow)
    tags: list[str] = field(default_factory=list)

    @property
    def total(self) -> float:
        return self.quantity * self.price


order = Order(id=1, product="Widget", quantity=5, price=9.99)
print(order.total)   # 49.95
print(order)         # Order(id=1, product='Widget', ...)

Frozen dataclasses (immutable)

Python
@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
p.x = 3.0   # raises FrozenInstanceError

Dataclass vs dict: when to use each

| Situation | Use | |-----------|-----| | Structured config with defaults | @dataclass | | Validated API models | Pydantic BaseModel | | Arbitrary key-value data | dict | | Immutable record | @dataclass(frozen=True) |


5. Abstract Base Classes (ABCs)

ABCs define a contract — subclasses must implement certain methods.

Python
from abc import ABC, abstractmethod

class DataSource(ABC):
    @abstractmethod
    def read(self) -> list[dict]:
        ...

    @abstractmethod
    def close(self) -> None:
        ...

    def read_all(self) -> list[dict]:   # concrete method  shared logic
        try:
            return self.read()
        finally:
            self.close()


class CSVSource(DataSource):
    def __init__(self, path: str) -> None:
        self.path = path

    def read(self) -> list[dict]:
        import csv
        with open(self.path) as f:
            return list(csv.DictReader(f))

    def close(self) -> None:
        pass  # files are closed by context manager


class DatabaseSource(DataSource):
    def read(self) -> list[dict]:
        ...  # query the database

    def close(self) -> None:
        ...  # close connection


# DataSource()  # raises TypeError  can't instantiate abstract class
source = CSVSource("data.csv")

6. Protocols (Structural Typing)

Protocols check for capability, not ancestry. A class doesn't need to inherit — it just needs the right methods.

Python
from typing import Protocol

class Runnable(Protocol):
    def run(self) -> None:
        ...

class Worker:
    def run(self) -> None:
        print("Worker running")

class Pipeline:
    def run(self) -> None:
        print("Pipeline running")

def start(task: Runnable) -> None:
    task.run()

start(Worker())    # works  Worker has .run()
start(Pipeline())  # works  Pipeline has .run()
# No inheritance from Runnable needed

Protocol with runtime check

Python
from typing import runtime_checkable

@runtime_checkable
class Closable(Protocol):
    def close(self) -> None:
        ...

class Connection:
    def close(self) -> None:
        ...

isinstance(Connection(), Closable)   # True

7. Dunder (Magic) Methods

Dunder methods let your class integrate with Python's built-in syntax.

Python
from typing import Iterator

class NumberRange:
    def __init__(self, start: int, stop: int) -> None:
        self.start = start
        self.stop = stop

    def __len__(self) -> int:
        return self.stop - self.start

    def __contains__(self, item: int) -> bool:
        return self.start <= item < self.stop

    def __iter__(self) -> Iterator[int]:
        return iter(range(self.start, self.stop))

    def __repr__(self) -> str:
        return f"NumberRange({self.start}, {self.stop})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, NumberRange):
            return NotImplemented
        return self.start == other.start and self.stop == other.stop


r = NumberRange(1, 11)
len(r)          # 10
5 in r          # True
list(r)         # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Context manager protocol

Python
class Timer:
    def __enter__(self) -> "Timer":
        import time
        self._start = time.perf_counter()
        return self

    def __exit__(self, *args: object) -> None:
        self.elapsed = time.perf_counter() - self._start
        print(f"Elapsed: {self.elapsed:.3f}s")


with Timer() as t:
    sum(range(1_000_000))

print(t.elapsed)

8. Composition Over Inheritance

Inheritance is often overused. Prefer composition when you want to reuse behaviour without coupling classes.

Python
class FileWriter:
    def write(self, path: str, content: str) -> None:
        with open(path, "w") as f:
            f.write(content)

class JsonSerializer:
    def serialize(self, data: dict) -> str:
        import json
        return json.dumps(data, indent=2)

class ReportExporter:
    def __init__(self) -> None:
        self._writer = FileWriter()         # composed
        self._serializer = JsonSerializer() # composed

    def export(self, data: dict, path: str) -> None:
        content = self._serializer.serialize(data)
        self._writer.write(path, content)

9. Real Pattern: Base Pipeline Step

This pattern is used in ETL pipelines, data processing tools, and framework internals.

Python
from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class StepResult:
    success: bool
    data: list[dict]
    errors: list[str]

class PipelineStep(ABC):
    name: str = "unnamed"

    @abstractmethod
    def process(self, data: list[dict]) -> StepResult:
        ...

    def __call__(self, data: list[dict]) -> StepResult:
        print(f"[{self.name}] Processing {len(data)} records")
        result = self.process(data)
        if not result.success:
            print(f"[{self.name}] Errors: {result.errors}")
        return result


class FilterNulls(PipelineStep):
    name = "filter_nulls"

    def process(self, data: list[dict]) -> StepResult:
        clean = [r for r in data if all(v is not None for v in r.values())]
        removed = len(data) - len(clean)
        return StepResult(success=True, data=clean, errors=[f"removed {removed} null rows"])


class NormalizeEmails(PipelineStep):
    name = "normalize_emails"

    def process(self, data: list[dict]) -> StepResult:
        for row in data:
            if "email" in row:
                row["email"] = row["email"].strip().lower()
        return StepResult(success=True, data=data, errors=[])


# run the pipeline
steps: list[PipelineStep] = [FilterNulls(), NormalizeEmails()]
current_data = [{"email": "  USER@EXAMPLE.COM  "}, {"email": None}]

for step in steps:
    result = step(current_data)
    current_data = result.data

Exercises

Exercise 1: Create a BankAccount class with deposit, withdraw, and a balance property. Raise ValueError on negative deposits or withdrawals that exceed balance.

Exercise 2: Define a Sortable Protocol with a sort_key property that returns a float. Write a function sort_items(items: list[Sortable]) -> list[Sortable] that uses it.

Exercise 3: Write a retry_on_error context manager class (using __enter__/__exit__) that catches exceptions and re-runs the block up to N times.


Summary

| Tool | Use When | |------|---------| | Plain class | Stateful objects with methods | | @dataclass | Structured records, config, DTOs | | Inheritance | "is-a" relationship, shared concrete logic | | ABC | Enforcing contracts in base classes | | Protocol | Duck-typing — structural interface without inheritance | | Properties | Computed attrs, validation on set | | Composition | Combining behaviours without tight coupling | | Dunder methods | Integrating with Python syntax (len, in, with) |

Next: file handling, scripting patterns, and CLI tools with argparse/Typer.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.