Aller au contenu

Architecture applicative

Organiser le code en couches claires pour que les règles métier ne dépendent jamais des détails techniques.


Le problème central

Sans architecture explicite, la logique métier finit toujours par se retrouver melangee aux appels HTTP, aux requêtes SQL et aux détails d'infrastructure. Résultat : tester un calcul de TVA nécessité une base de données réelle.

L'objectif de toutes les architectures ci-dessous est le même :

Les règles métier ne dépendent pas de la technologie. La technologie dépend des règles métier.


Vue d'ensemble comparative

graph LR
    subgraph Layered["Layered (traditionnel)"]
        direction TB
        L1["Presentation"] --> L2["Business"]
        L2 --> L3["Data Access"]
    end
    subgraph Clean["Clean / Hexagonal"]
        direction TB
        C1["Frameworks & Drivers"]
        C2["Interface Adapters"]
        C3["Use Cases"]
        C4["Entities"]
        C1 --> C2 --> C3 --> C4
    end

Clean Architecture

Robert Martin (Uncle Bob) propose quatre cercles concentriques. La règle absolue : les dépendances ne pointent que vers l'intérieur.

graph TD
    FW["Frameworks & Drivers<br/>(Web, DB, UI)"]
    IA["Interface Adapters<br/>(Controllers)"]
    UC["Use Cases<br/>(App logic)"]
    EN["Entities<br/>(Domain)"]
    FW --> IA --> UC --> EN

    style EN fill:#2d6a4f,stroke:#1b4332,color:#fff
    style UC fill:#40916c,stroke:#2d6a4f,color:#fff
    style IA fill:#74c69d,stroke:#40916c,color:#000
    style FW fill:#b7e4c7,stroke:#74c69d,color:#000

Structure projet typique en Python :

src/
  domain/
    entities/
      order.py          # Order, OrderItem — pas d'import externe
    value_objects/
      money.py
  application/
    use_cases/
      create_order.py   # orchestre domain + ports
    ports/
      order_repository.py  # interface abstraite
  infrastructure/
    persistence/
      postgres_order_repo.py  # implemente le port
    web/
      order_controller.py     # FastAPI / Flask
# domain/entities/order.py — zero dependance externe
class Order:
    def __init__(self, customer_id: str):
        self.customer_id = customer_id
        self.items: list = []
        self.status = "draft"

    def add_item(self, product_id: str, qty: int, price: float):
        self.items.append({"product_id": product_id, "qty": qty, "price": price})

    def total(self) -> float:
        return sum(i["qty"] * i["price"] for i in self.items)

    def confirm(self):
        if not self.items:
            raise ValueError("Commande vide")
        self.status = "confirmed"

Architecture Hexagonale (Ports & Adapters)

Proposee par Alistair Cockburn, elle nomme explicitement les points d'entree et de sortie.

graph LR
    subgraph Core["Application Core"]
        UC["Use Cases"]
        DOM["Domain"]
    end
    HTTP["HTTP Adapter"] -->|driving port| UC
    CLI["CLI Adapter"] -->|driving port| UC
    UC -->|driven port| DB["DB Adapter"]
    UC -->|driven port| SMTP["SMTP Adapter"]
  • Ports primaires (driving) : ce que l'application expose (API REST, CLI, message queue)
  • Ports secondaires (driven) : ce dont l'application a besoin (base de données, emails, services tiers)
  • Adapters : implémentations concrètes des ports
# application/ports/order_repository.py
from abc import ABC, abstractmethod
from domain.entities.order import Order

class OrderRepository(ABC):
    @abstractmethod
    def save(self, order: Order) -> None: pass

    @abstractmethod
    def find_by_id(self, order_id: str) -> Order | None: pass


# infrastructure/persistence/postgres_order_repo.py
class PostgresOrderRepository(OrderRepository):
    def __init__(self, session):
        self._session = session

    def save(self, order: Order) -> None:
        # mapping domain -> ORM
        pass

    def find_by_id(self, order_id: str) -> Order | None:
        pass


# infrastructure/persistence/in_memory_order_repo.py — pour les tests
class InMemoryOrderRepository(OrderRepository):
    def __init__(self):
        self._store: dict[str, Order] = {}

    def save(self, order: Order) -> None:
        self._store[id(order)] = order

    def find_by_id(self, order_id: str) -> Order | None:
        return self._store.get(order_id)

L'adapteur in-memory est votre meilleur ami

Implémenter un port en mémoire prend 10 lignes et rend vos tests de use cases 100x plus rapides que des tests avec base de données réelle.


Onion Architecture

Variante de Clean Architecture par Jeffrey Palermo. Les couches sont les mêmes mais la terminologie différé :

Couche Contenu
Domain Model Entités, Value Objects, logique pure
Domain Services Services operant sur plusieurs entités
Application Use cases, orchestration, ports
Infrastructure DB, HTTP, fichiers, services externes

La règle reste la même : les imports vont de l'extérieur vers l'intérieur, jamais l'inverse.


Monolithe modulaire vs microservices

Avant de passer aux microservices, essayez le monolithe modulaire :

src/
  modules/
    orders/
      domain/
      application/
      infrastructure/
    billing/
      domain/
      application/
      infrastructure/
    catalog/
      ...
  shared/
    events/   # communication inter-modules par evenements
Critère Monolithe modulaire Microservices
Complexité initiale Faible Élevée (infra, réseau, observabilité)
Déploiement Simple Complexe (orchestration)
Isolation des pannes Partielle Forte
Équipe 1-10 personnes Plusieurs équipes autonomes
Quand migrer Quand les modules divergent Jamais si le monolithe suffit

Commencez modulaire, pas micro

La majorité des startups qui ont commence avec des microservices les ont regroupes en monolithe après 18 mois. Construisez des modules bien isoles d'abord — la migration vers des services indépendants devient triviale si les frontieres sont propres.