Motifs de Conception Python
Écrivez du code Python maintenable en utilisant les principes de conception fondamentaux. Ces motifs vous aident à construire des systèmes faciles à comprendre, tester et modifier.
Quand utiliser cette compétence
- Concevoir de nouveaux composants ou services
- Refactoriser du code complexe ou enchevêtré
- Décider de créer une abstraction
- Choisir entre héritage et composition
- Évaluer la complexité du code et l'accouplement
- Planifier des architectures modulaires
Concepts Fondamentaux
1. KISS (Keep It Simple)
Choisissez la solution la plus simple qui fonctionne. La complexité doit être justifiée par des exigences concrètes.
2. Single Responsibility (SRP)
Chaque unité devrait avoir une seule raison de changer. Séparez les préoccupations en composants ciblés.
3. Composition Over Inheritance
Construisez le comportement en combinant des objets, pas en étendant des classes.
4. Rule of Three
Attendez d'avoir trois instances avant d'abstraire. La duplication est souvent préférable à une abstraction prématurée.
Démarrage Rapide
# Simple beats clever
# Instead of a factory/registry pattern:
FORMATTERS = {"json": JsonFormatter, "csv": CsvFormatter}
def get_formatter(name: str) -> Formatter:
return FORMATTERS[name]()
Motifs Fondamentaux
Motif 1 : KISS - Keep It Simple
Avant d'ajouter de la complexité, demandez-vous : une solution plus simple ferait-elle l'affaire ?
# Over-engineered: Factory with registration
class OutputFormatterFactory:
_formatters: dict[str, type[Formatter]] = {}
@classmethod
def register(cls, name: str):
def decorator(formatter_cls):
cls._formatters[name] = formatter_cls
return formatter_cls
return decorator
@classmethod
def create(cls, name: str) -> Formatter:
return cls._formatters[name]()
@OutputFormatterFactory.register("json")
class JsonFormatter(Formatter):
...
# Simple: Just use a dictionary
FORMATTERS = {
"json": JsonFormatter,
"csv": CsvFormatter,
"xml": XmlFormatter,
}
def get_formatter(name: str) -> Formatter:
"""Get formatter by name."""
if name not in FORMATTERS:
raise ValueError(f"Unknown format: {name}")
return FORMATTERS[name]()
Le motif factory ajoute du code sans ajouter de valeur ici. Réservez les motifs à quand ils résolvent de vrais problèmes.
Motif 2 : Single Responsibility Principle
Chaque classe ou fonction devrait avoir une seule raison de changer.
# BAD: Handler does everything
class UserHandler:
async def create_user(self, request: Request) -> Response:
# HTTP parsing
data = await request.json()
# Validation
if not data.get("email"):
return Response({"error": "email required"}, status=400)
# Database access
user = await db.execute(
"INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *",
data["email"], data["name"]
)
# Response formatting
return Response({"id": user.id, "email": user.email}, status=201)
# GOOD: Separated concerns
class UserService:
"""Business logic only."""
def __init__(self, repo: UserRepository) -> None:
self._repo = repo
async def create_user(self, data: CreateUserInput) -> User:
# Only business rules here
user = User(email=data.email, name=data.name)
return await self._repo.save(user)
class UserHandler:
"""HTTP concerns only."""
def __init__(self, service: UserService) -> None:
self._service = service
async def create_user(self, request: Request) -> Response:
data = CreateUserInput(**(await request.json()))
user = await self._service.create_user(data)
return Response(user.to_dict(), status=201)
Maintenant les changements HTTP n'affectent pas la logique métier, et vice versa.
Motif 3 : Separation of Concerns
Organisez le code en couches distinctes avec des responsabilités claires.
┌─────────────────────────────────────────────────────┐
│ API Layer (handlers) │
│ - Parse requests │
│ - Call services │
│ - Format responses │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Service Layer (business logic) │
│ - Domain rules and validation │
│ - Orchestrate operations │
│ - Pure functions where possible │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Repository Layer (data access) │
│ - SQL queries │
│ - External API calls │
│ - Cache operations │
└─────────────────────────────────────────────────────┘
Chaque couche dépend uniquement des couches en dessous :
# Repository: Data access
class UserRepository:
async def get_by_id(self, user_id: str) -> User | None:
row = await self._db.fetchrow(
"SELECT * FROM users WHERE id = $1", user_id
)
return User(**row) if row else None
# Service: Business logic
class UserService:
def __init__(self, repo: UserRepository) -> None:
self._repo = repo
async def get_user(self, user_id: str) -> User:
user = await self._repo.get_by_id(user_id)
if user is None:
raise UserNotFoundError(user_id)
return user
# Handler: HTTP concerns
@app.get("/users/{user_id}")
async def get_user(user_id: str) -> UserResponse:
user = await user_service.get_user(user_id)
return UserResponse.from_user(user)
Motif 4 : Composition Over Inheritance
Construisez le comportement en combinant des objets plutôt qu'en héritage.
# Inheritance: Rigid and hard to test
class EmailNotificationService(NotificationService):
def __init__(self):
super().__init__()
self._smtp = SmtpClient() # Hard to mock
def notify(self, user: User, message: str) -> None:
self._smtp.send(user.email, message)
# Composition: Flexible and testable
class NotificationService:
"""Send notifications via multiple channels."""
def __init__(
self,
email_sender: EmailSender,
sms_sender: SmsSender | None = None,
push_sender: PushSender | None = None,
) -> None:
self._email = email_sender
self._sms = sms_sender
self._push = push_sender
async def notify(
self,
user: User,
message: str,
channels: set[str] | None = None,
) -> None:
channels = channels or {"email"}
if "email" in channels:
await self._email.send(user.email, message)
if "sms" in channels and self._sms and user.phone:
await self._sms.send(user.phone, message)
if "push" in channels and self._push and user.device_token:
await self._push.send(user.device_token, message)
# Easy to test with fakes
service = NotificationService(
email_sender=FakeEmailSender(),
sms_sender=FakeSmsSender(),
)
Motifs Avancés
Motif 5 : Rule of Three
Attendez d'avoir trois instances avant d'abstraire.
# Two similar functions? Don't abstract yet
def process_orders(orders: list[Order]) -> list[Result]:
results = []
for order in orders:
validated = validate_order(order)
result = process_validated_order(validated)
results.append(result)
return results
def process_returns(returns: list[Return]) -> list[Result]:
results = []
for ret in returns:
validated = validate_return(ret)
result = process_validated_return(validated)
results.append(result)
return results
# These look similar, but wait! Are they actually the same?
# Different validation, different processing, different errors...
# Duplication is often better than the wrong abstraction
# Only after a third case, consider if there's a real pattern
# But even then, sometimes explicit is better than abstract
Motif 6 : Function Size Guidelines
Gardez les fonctions ciblées. Extrayez quand une fonction :
- Dépasse 20-50 lignes (varie selon la complexité)
- Remplit plusieurs objectifs distincts
- A une logique profondément imbriquée (3+ niveaux)
# Too long, multiple concerns mixed
def process_order(order: Order) -> Result:
# 50 lines of validation...
# 30 lines of inventory check...
# 40 lines of payment processing...
# 20 lines of notification...
pass
# Better: Composed from focused functions
def process_order(order: Order) -> Result:
"""Process a customer order through the complete workflow."""
validate_order(order)
reserve_inventory(order)
payment_result = charge_payment(order)
send_confirmation(order, payment_result)
return Result(success=True, order_id=order.id)
Motif 7 : Dependency Injection
Passez les dépendances via les constructeurs pour la testabilité.
from typing import Protocol
class Logger(Protocol):
def info(self, msg: str, **kwargs) -> None: ...
def error(self, msg: str, **kwargs) -> None: ...
class Cache(Protocol):
async def get(self, key: str) -> str | None: ...
async def set(self, key: str, value: str, ttl: int) -> None: ...
class UserService:
"""Service with injected dependencies."""
def __init__(
self,
repository: UserRepository,
cache: Cache,
logger: Logger,
) -> None:
self._repo = repository
self._cache = cache
self._logger = logger
async def get_user(self, user_id: str) -> User:
# Check cache first
cached = await self._cache.get(f"user:{user_id}")
if cached:
self._logger.info("Cache hit", user_id=user_id)
return User.from_json(cached)
# Fetch from database
user = await self._repo.get_by_id(user_id)
if user:
await self._cache.set(f"user:{user_id}", user.to_json(), ttl=300)
return user
# Production
service = UserService(
repository=PostgresUserRepository(db),
cache=RedisCache(redis),
logger=StructlogLogger(),
)
# Testing
service = UserService(
repository=InMemoryUserRepository(),
cache=FakeCache(),
logger=NullLogger(),
)
Motif 8 : Avoiding Common Anti-Patterns
Don't expose internal types:
# BAD: Leaking ORM model to API
@app.get("/users/{id}")
def get_user(id: str) -> UserModel: # SQLAlchemy model
return db.query(UserModel).get(id)
# GOOD: Use response schemas
@app.get("/users/{id}")
def get_user(id: str) -> UserResponse:
user = db.query(UserModel).get(id)
return UserResponse.from_orm(user)
Don't mix I/O with business logic:
# BAD: SQL embedded in business logic
def calculate_discount(user_id: str) -> float:
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
orders = db.query("SELECT * FROM orders WHERE user_id = ?", user_id)
# Business logic mixed with data access
# GOOD: Repository pattern
def calculate_discount(user: User, order_history: list[Order]) -> float:
# Pure business logic, easily testable
if len(order_history) > 10:
return 0.15
return 0.0
Résumé des Bonnes Pratiques
- Keep it simple - Choisissez la solution la plus simple qui fonctionne
- Single responsibility - Chaque unité a une seule raison de changer
- Separate concerns - Couches distinctes avec des objectifs clairs
- Compose, don't inherit - Combinez les objets pour la flexibilité
- Rule of three - Attendez avant d'abstraire
- Keep functions small - 20-50 lignes (varie selon la complexité), un objectif
- Inject dependencies - Injection par constructeur pour la testabilité
- Delete before abstracting - Supprimez le code mort, puis envisagez les motifs
- Test each layer - Tests isolés pour chaque préoccupation
- Explicit over clever - Le code lisible bat le code élégant
Dépannage
Une classe s'agrandit et semble avoir plusieurs responsabilités, mais la scinder semble mal. Appliquez le test « raison de changer » : énumérez chaque changement qui pourrait nécessiter d'éditer cette classe. Si la liste contient des éléments de domaines différents (ex. parsing HTTP ET règles métier ET formatage), scindez-la. Si tous les changements proviennent de la même préoccupation de domaine, la classe peut être appropriée.
L'injection de toutes les dépendances via le constructeur produit des constructeurs avec 7+ paramètres. C'est un signe de trop de responsabilités dans une classe, pas un problème avec l'injection de dépendances. Scindez d'abord la classe en unités plus petites, puis chaque constructeur devient naturellement plus petit.
La composition produit des objets wrapper profondément imbriqués qui sont difficiles à tracer. Gardez la composition peu profonde (2-3 niveaux). Si l'enveloppe est le seul mécanisme, envisagez si une approche basée sur Protocol ou la composition simple de fonctions serait plus propre qu'une chaîne d'objets décorateurs.
La rule of three dit de ne pas encore abstraire, mais la duplication cause des bugs quand une copie est mise à jour mais pas l'autre. La duplication qui diverge de manière dangereuse devrait être abstraite plus tôt. La rule of three est une heuristique, pas une loi. Si les copies divergent déjà incorrectement, extrayez immédiatement et ajoutez un test qui exerce le comportement partagé.
Une couche de service importe de la couche API, cassant la direction de la dépendance. C'est une violation de couche. La couche de service ne doit pas importer de handlers. Introduisez une couche de types/modèles partagés que les deux peuvent importer, gardant la flèche de dépendance pointant vers le bas (API → Service → Repository).
Compétences Connexes
- python-testing-patterns — Testez chaque couche isolément en utilisant la structure d'injection de dépendances établie ici
- python-project-setup — Configurez la structure et les outils de projet qui appliquent les limites de couche dès le départ