Python OOP and Design Patterns in Practice

Python supports multiple programming paradigms, but its object-oriented features are among the most powerful and frequently misunderstood. This article covers how to write clean, idiomatic Python classes and which design patterns translate naturally from theory into Python codebases.

Python Class Fundamentals Done Right

Before patterns, the basics need to be solid.

Use __repr__ and __str__

Every class you create should have a __repr__. It makes debugging dramatically easier.

class Article:
    def __init__(self, id: int, title: str, published: bool = False):
        self.id = id
        self.title = title
        self.published = published

    def __repr__(self) -> str:
        return f"Article(id={self.id!r}, title={self.title!r}, published={self.published})"

    def __str__(self) -> str:
        status = "Published" if self.published else "Draft"
        return f"{self.title} [{status}]"

__repr__ is for developers (should be unambiguous), __str__ is for end users (should be readable).

Prefer dataclasses for Data-Holding Classes

from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class Article:
    id: int
    title: str
    tags: list[str] = field(default_factory=list)
    published_at: datetime | None = None

    @property
    def is_published(self) -> bool:
        return self.published_at is not None

@dataclass auto-generates __init__, __repr__, and __eq__. Add frozen=True to make it immutable (and hashable).

Use @property for Computed Attributes

Don't write get_* methods. Use @property to expose computed values as attributes.

class Circle:
    def __init__(self, radius: float):
        self._radius = radius

    @property
    def radius(self) -> float:
        return self._radius

    @radius.setter
    def radius(self, value: float) -> None:
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

    @property
    def area(self) -> float:
        import math
        return math.pi * self._radius ** 2

Design Pattern 1: Singleton

Ensures only one instance of a class exists. Use it for shared resources like config loaders or database connection pools.

class DatabasePool:
    _instance: "DatabasePool | None" = None

    def __new__(cls) -> "DatabasePool":
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._pool = cls._create_pool()
        return cls._instance

    @staticmethod
    def _create_pool():
        # create and return connection pool
        ...

# Both calls return the same object
pool1 = DatabasePool()
pool2 = DatabasePool()
assert pool1 is pool2  # True

When to use: Logger, config manager, connection pool. When to avoid: It makes unit testing harder. Prefer dependency injection for most cases.

Design Pattern 2: Factory Method

Creates objects without specifying the exact class upfront. Great for building objects based on config or type strings.

from abc import ABC, abstractmethod

class Notification(ABC):
    @abstractmethod
    def send(self, message: str) -> None: ...

class EmailNotification(Notification):
    def send(self, message: str) -> None:
        print(f"Email: {message}")

class SlackNotification(Notification):
    def send(self, message: str) -> None:
        print(f"Slack: {message}")

class NotificationFactory:
    _registry: dict[str, type[Notification]] = {
        "email": EmailNotification,
        "slack": SlackNotification,
    }

    @classmethod
    def create(cls, channel: str) -> Notification:
        klass = cls._registry.get(channel)
        if klass is None:
            raise ValueError(f"Unknown channel: {channel!r}")
        return klass()

# Usage
notifier = NotificationFactory.create("slack")
notifier.send("Deploy succeeded")

Design Pattern 3: Strategy

Encapsulates a family of algorithms and makes them interchangeable. Avoids long if/elif chains.

from typing import Protocol

class SortStrategy(Protocol):
    def sort(self, data: list[int]) -> list[int]: ...

class BubbleSort:
    def sort(self, data: list[int]) -> list[int]:
        arr = data[:]
        for i in range(len(arr)):
            for j in range(len(arr) - i - 1):
                if arr[j] > arr[j + 1]:
                    arr[j], arr[j + 1] = arr[j + 1], arr[j]
        return arr

class QuickSort:
    def sort(self, data: list[int]) -> list[int]:
        if len(data) <= 1:
            return data
        pivot = data[len(data) // 2]
        left   = [x for x in data if x < pivot]
        middle = [x for x in data if x == pivot]
        right  = [x for x in data if x > pivot]
        return self.sort(left) + middle + self.sort(right)

class Sorter:
    def __init__(self, strategy: SortStrategy) -> None:
        self._strategy = strategy

    def sort(self, data: list[int]) -> list[int]:
        return self._strategy.sort(data)

sorter = Sorter(QuickSort())
print(sorter.sort([5, 3, 8, 1]))

Note the use of Protocol instead of an abstract base class. This is duck typing at its most Pythonic.

Design Pattern 4: Observer

Allows objects to notify subscribers when their state changes. Python's __call__ protocol makes this clean.

from typing import Callable

class EventEmitter:
    def __init__(self) -> None:
        self._listeners: dict[str, list[Callable]] = {}

    def on(self, event: str, callback: Callable) -> None:
        self._listeners.setdefault(event, []).append(callback)

    def emit(self, event: str, *args, **kwargs) -> None:
        for callback in self._listeners.get(event, []):
            callback(*args, **kwargs)

# Usage
emitter = EventEmitter()
emitter.on("article_published", lambda a: print(f"New article: {a.title}"))
emitter.on("article_published", lambda a: send_newsletter(a))

emitter.emit("article_published", article)

Design Pattern 5: Decorator Pattern (not the syntax)

Python's @decorator syntax and the Decorator design pattern are different things. The pattern wraps an object to extend its behaviour, which is useful for adding logging, caching, or validation without changing the original class.

class ArticleRepository:
    def find_by_id(self, article_id: int) -> Article | None:
        return db.query(Article).get(article_id)

class CachedArticleRepository:
    def __init__(self, repo: ArticleRepository) -> None:
        self._repo = repo
        self._cache: dict[int, Article] = {}

    def find_by_id(self, article_id: int) -> Article | None:
        if article_id not in self._cache:
            self._cache[article_id] = self._repo.find_by_id(article_id)
        return self._cache[article_id]

Design Pattern 6: Context Manager as a Pattern

Python's with protocol is itself a design pattern for resource management. You can implement it on any class:

class ManagedTransaction:
    def __init__(self, session):
        self.session = session

    def __enter__(self):
        self.session.begin()
        return self.session

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type:
            self.session.rollback()
        else:
            self.session.commit()
        return False  # do not suppress exceptions

with ManagedTransaction(db_session) as tx:
    tx.add(new_article)

When NOT to Use Patterns

Design patterns solve specific structural problems. They are not badges of sophistication.

  • Do not use a Singleton when a module-level variable or dependency injection would do.
  • Do not use a Factory when a simple if/else is the entire logic.
  • Do not introduce an Observer system when a direct function call would be clearer.

The best Python code often has no recognisable pattern. Just small, focused classes and functions that compose cleanly.

Conclusion

Python's OOP is most effective when it stays simple: lean classes, clear properties, and protocols over inheritance hierarchies. Design patterns are tools. Reach for them when the problem calls for them, not to demonstrate architectural knowledge.


References: