python-design-patterns

Par wshobson · agents

Patrons de conception Python incluant KISS, la séparation des responsabilités, le principe de responsabilité unique et la composition plutôt que l'héritage. Utilisez cette compétence lors de la conception d'un nouveau service ou composant de zéro et du choix de la façon de stratifier les responsabilités, lors du refactoring d'une God class ou d'une fonction monolithique devenue trop volumineuse, lorsque vous devez décider d'ajouter une nouvelle abstraction ou de tolérer une duplication, lors de l'évaluation d'une pull request pour des problèmes structurels tels qu'un couplage fort ou la fuite de types internes, lors du choix entre héritage et composition pour une nouvelle hiérarchie de classes, ou lorsqu'une base de code devient difficile à tester en raison d'une logique I/O et métier entremêlée.

npx skills add https://github.com/wshobson/agents --skill python-design-patterns

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

  1. Keep it simple - Choisissez la solution la plus simple qui fonctionne
  2. Single responsibility - Chaque unité a une seule raison de changer
  3. Separate concerns - Couches distinctes avec des objectifs clairs
  4. Compose, don't inherit - Combinez les objets pour la flexibilité
  5. Rule of three - Attendez avant d'abstraire
  6. Keep functions small - 20-50 lignes (varie selon la complexité), un objectif
  7. Inject dependencies - Injection par constructeur pour la testabilité
  8. Delete before abstracting - Supprimez le code mort, puis envisagez les motifs
  9. Test each layer - Tests isolés pour chaque préoccupation
  10. 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

Skills similaires