Modern Object-Oriented Python and Data Modeling
Build scalable Python systems using modern OOP, typing Protocols (structural subtyping), dataclasses, Pydantic v2 data validation, and Pythonic design patterns.
Chapter Objectives
- Master Python class internals, instance vs class state, and C3 Method Resolution Order
- Apply structural subtyping using typing.Protocol instead of rigid ABC hierarchies
- Model domain entities with @dataclass, frozen immutability, and slots memory optimization
- Enforce boundary validation and serialization using Pydantic v2 BaseModels
- Replace heavy Gang of Four patterns with lightweight, idiomatic Python constructs
Modern Object-Oriented Python and Data Modeling
Python's object model is fundamentally dynamic: classes are first-class runtime objects, methods are descriptors, and interfaces can be verified structurally (duck typing) or nominally. While earlier generations of Python leaned heavily on deep inheritance trees, production systems today favor composition, structural Protocols, and concise data models powered by @dataclass and Pydantic.
1. Classes Are First-Class Runtime Objects
In Python, executing a class statement is not a compile-time declaration: it is an executable block that creates a new class object and binds it to the module namespace.
class ServiceConfig:
# Class-level attribute: shared across ALL instances
default_timeout: int = 30
def __init__(self, service_name: str, custom_timeout: int | None = None) -> None:
# Instance attributes: unique to each instance
self.service_name = service_name
self.timeout = custom_timeout if custom_timeout is not None else self.default_timeout
# Modifying class attributes affects instances that do not override them
c1 = ServiceConfig("auth")
c2 = ServiceConfig("billing")
ServiceConfig.default_timeout = 60
print(c1.timeout) # 30 (evaluated at init)
Memory Optimization with __slots__
By default, Python instances store attributes in an internal dictionary (__dict__). For systems that instantiate millions of records, this dictionary introduces substantial memory overhead:
class CoordinateStandard:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
class CoordinateSlotted:
# Prevents creation of __dict__ and __weakref__
__slots__ = ('x', 'y')
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
Slotted classes reduce memory usage by 40-60% per instance and prevent accidental dynamic attribute assignment.
2. Structural Subtyping with typing.Protocol
Historically, Python developers used Abstract Base Classes (abc.ABC) to declare interface contracts. ABCs rely on nominal subtyping: a class must explicitly inherit from the ABC to satisfy the type checker.
Python 3.8+ introduced typing.Protocol (PEP 544), enabling static structural subtyping (static duck typing). Any class that implements the required methods and properties satisfies the Protocol automatically without subclassing.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Notifier(Protocol):
def send_notification(self, recipient: str, message: str) -> bool:
...
# No inheritance needed from Notifier
class EmailService:
def send_notification(self, recipient: str, message: str) -> bool:
print(f"Dispatching email to {recipient}: {message}")
return True
class SlackService:
def send_notification(self, recipient: str, message: str) -> bool:
print(f"Posting to Slack channel {recipient}: {message}")
return True
class IncompatibleService:
def alert(self, msg: str) -> None:
pass
def broadcast_alert(client: Notifier, target: str, alert_text: str) -> None:
client.send_notification(target, alert_text)
# Both EmailService and SlackService pass static checks and runtime verification
broadcast_alert(EmailService(), "ops@locionic.com", "Deploy completed")
broadcast_alert(SlackService(), "#alerts", "High latency detected")
Protocols vs. Abstract Base Classes
Prefer typing.Protocol for interface boundaries, dependency injection, and library contracts. It decouples implementations from interfaces and allows external third-party classes to satisfy your interface without monkey-patching or wrapping.
3. Method Resolution Order (MRO) and C3 Linearization
When multiple inheritance is used, Python determines attribute and method resolution using the C3 Linearization Algorithm.
class Base:
def execute(self) -> None:
print("Base execution")
class MetricsMixin(Base):
def execute(self) -> None:
print("Emitting start metric")
super().execute()
print("Emitting completion metric")
class LoggingMixin(Base):
def execute(self) -> None:
print("Logging start")
super().execute()
print("Logging finish")
class WorkerTask(MetricsMixin, LoggingMixin):
pass
task = WorkerTask()
task.execute()
# Order: MetricsMixin -> LoggingMixin -> Base
print(WorkerTask.__mro__)
What does super() return?
What does super() return?
4. Data Modeling: Dataclasses vs. Pydantic v2
Modern Python provides two standard approaches to data modeling: standard library @dataclass for lightweight internal models, and Pydantic v2 for external boundary validation.
Standard Library @dataclass
from dataclasses import dataclass, field
import uuid
@dataclass(frozen=True, slots=True, order=True)
class APIKey:
key_id: str = field(default_factory=lambda: str(uuid.uuid4()))
owner: str = field(compare=True)
rate_limit: int = field(default=100, compare=False)
scopes: tuple[str, ...] = field(default_factory=tuple, compare=False)
# frozen=True: instance is immutable and hashable
# slots=True: optimized memory layout
# order=True: generates __lt__, __le__, etc. based on fields
Pydantic v2 for Boundary Validation
When receiving data over HTTP, Kafka, or configuration files, standard dataclasses do not validate runtime types (e.g. they accept strings where integers were declared). Pydantic v2 provides Rust-backed validation and schema coercion:
from pydantic import BaseModel, Field, EmailStr, HttpUrl
from datetime import datetime
class WebhookPayload(BaseModel):
event_id: str = Field(min_length=8)
user_email: EmailStr
callback_url: HttpUrl
retry_count: int = Field(ge=0, le=5, default=0)
created_at: datetime = Field(default_factory=datetime.utcnow)
model_config = {
"frozen": True,
"str_strip_whitespace": True
}
# Automatic parsing, type coercion, and schema enforcement
payload = WebhookPayload.model_validate({
"event_id": "evt_99881122",
"user_email": "engineer@locionic.com",
"callback_url": "https://api.locionic.com/hooks/v1",
"retry_count": "2", # String automatically coerced to integer
})
5. Pythonic Design Patterns: Zero-Ceremony Architecture
Classic Gang of Four (GoF) patterns were designed for languages like Java or C++ where functions cannot exist outside classes and types are strictly nominal. In Python, first-class functions and modules render many heavy patterns obsolete.
The Singleton Pattern: Use a Python Module
In Python, modules are cached in sys.modules on first import. A module is inherently a thread-safe singleton:
# db_connection.py: A module is already a singleton!
_connection_pool = None
def get_connection():
global _connection_pool
if _connection_pool is None:
_connection_pool = initialize_pool()
return _connection_pool
The Strategy Pattern: First-Class Callables
Instead of creating an abstract PaymentStrategy class with an execute_payment method and concrete subclasses, pass functions or callables directly:
from typing import Callable
# Strategies are just functions
def stripe_processor(amount: int, currency: str) -> str:
return f"Processed {amount} {currency} via Stripe"
def paypal_processor(amount: int, currency: str) -> str:
return f"Processed {amount} {currency} via PayPal"
PaymentHandler = Callable[[int, str], str]
def checkout(amount: int, currency: str, handler: PaymentHandler) -> str:
return handler(amount, currency)
# Invoke with direct function reference or lambda
checkout(5000, "USD", stripe_processor)
The Factory Pattern: Dictionary Dispatch
Replace verbose factory classes with dictionary mappings:
from typing import Callable, Protocol
class Parser(Protocol):
def parse(self, raw: str) -> dict: ...
PARSER_REGISTRY: dict[str, Callable[[], Parser]] = {
"json": JSONParser,
"xml": XMLParser,
"yaml": YAMLParser,
}
def get_parser(format_type: str) -> Parser:
creator = PARSER_REGISTRY.get(format_type.lower())
if not creator:
raise ValueError(f"Unsupported parser format: {format_type}")
return creator()
Interactive Knowledge Checks
Define a Structural Storage Protocol
mediumDefine a typing.Protocol named 'BlobStorage' with two methods: upload(key: str, data: bytes) -> bool and download(key: str) -> bytes. Implement a concrete InMemoryBlobStorage class that satisfies this protocol without inheriting from it.
Chapter Summary
- Classes are runtime objects: Optimize high-frequency instances using
__slots__to remove per-instance dictionaries and drop memory consumption. - Favor Protocols over ABCs: Structural subtyping (
typing.Protocol) decouples your interfaces from third-party implementations. - Understand MRO: C3 linearization dictates multiple inheritance resolution. Always use
super()cooperatively. - Data modeling strategy: Use
@dataclass(frozen=True, slots=True)for internal immutable domain models; use Pydantic v2 for validating untrusted input at network boundaries. - Pythonic design patterns: Replace heavy GoF Singletons with modules, Strategies with Callables, and Factories with dictionary dispatch tables.