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/elseis 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: