Aller au contenu

Structure canonique d'un service mic-struct

TL;DR : tout service Python suit la structure app/{bootstrap,entrypoints,frameworks,modules,platform,shared}/ identique à celle de rdlink-api. Les modules métier sont strictement décomposés en 5 couches — controller/ + interface/http/v1/ + model/{business,persistence,repositories}/ — et exposent leurs routes sous /api/v1.

La structure complète

<service-name>/
├── app/                              # ← package (PAS src/<name>/)
│   ├── __init__.py
│   ├── bootstrap/
│   │   ├── config/settings.py        # pydantic-settings
│   │   ├── context/execution_context.py  # conteneur DI
│   │   ├── providers/                # factories de dépendances
│   │   │   ├── auth.py
│   │   │   ├── database.py
│   │   │   └── …
│   │   ├── startup/
│   │   │   ├── http_routes.py        # all_routes(ctx)
│   │   │   └── http_runtime.py       # build_app()
│   │   └── wiring/                   # 1 fichier par module
│   │       ├── system.py
│   │       └── <module>.py
│   ├── entrypoints/
│   │   ├── cli/                      # workers, scripts
│   │   └── http/main.py              # uvicorn entry
│   ├── frameworks/
│   │   ├── fastapi/middlewares/      # middlewares personnalisés
│   │   ├── litestar/                 # placeholder (futur)
│   │   └── cli/                      # placeholder
│   ├── modules/
│   │   ├── system/                   # health + ready (toujours présent, non versionné)
│   │   │   ├── controller/{health,readiness}.py
│   │   │   └── interface/http/routes.py
│   │   └── <metier>/                 # module métier — 5 couches
│   │       ├── controller/<action>.py              # cas d'usage (classe), 1 fichier/action
│   │       ├── interface/http/v1/                  # routes versionnées /api/v1
│   │       │   ├── <action>.py                      #   payload/response + handler
│   │       │   └── routes.py                        #   build_<module>_v1_routes(...)
│   │       └── model/
│   │           ├── business/<action>.py            # résultat métier (StrictSchema)
│   │           ├── persistence/<entity>_record.py  # SQLModel (<Entity>Record)
│   │           └── repositories/<entity>_repository.py  # accès persistance
│   ├── platform/services/            # clients/gateways externes partagés
│   └── shared/                       # placeholder (= utiliser mic.*)
├── tests/                            # mirror app/
├── pyproject.toml                    # packages = ["app"]
└── Makefile                          # gate target obligatoire

Les 5 couches du module métier

Chaque module métier (app/modules/<domain>/) se décompose en 5 couches, chacune avec son test miroir. Le sens de dépendance est unidirectionnel : interface → controller → repository → persistence, avec business comme contrat de résultat partagé.

  • controller/ — cas d'usage, 1 classe par action (EnqueueMailController.execute(...)). Porte les règles métier (validation, DomainError) puis délègue la persistance au repository. Aucune dépendance HTTP, aucun framework, jamais de Session en direct.
  • interface/http/v1/ — traduction HTTP ↔ controller, versionnée /api/v1 : un fichier par action (<action>.py = payload/response StrictSchema + handle_<action>) + routes.py (build_<module>_v1_routes(...) → HttpRouteSpec framework-agnostiques). Les mêmes controllers se rebranchent derrière interface/grpc/ ou un CLI.
  • model/business/ — résultats de cas d'usage (StrictSchema), découplés de l'enregistrement de persistance et de la réponse HTTP.
  • model/persistence/ — <entity>_record.py : SQLModel table=True (<Entity>Record), identité sous forme de chaîne UUID générée côté app, sans logique.
  • model/repositories/ — <entity>_repository.py : seul point d'accès à la persistance, encapsule la Session et renvoie des schémas business (jamais des enregistrements nus).

→ Le détail fichier par fichier, avec exemples de code complets, est dans Anatomie d'un module.

Bootstrap : DI propre

ExecutionContext — frozen dataclass

# app/bootstrap/context/execution_context.py
from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class ExecutionContext:
    settings: Settings
    database: SqlDataStorage
    readiness_checker: ReadinessChecker
    # + dépendances spécifiques
    transport: MailTransport | None = None
    outbox: OutboxStore | None = None

Providers — factories injectables

# app/bootstrap/providers/database.py
from mic.datastorage import SqlDataStorage

def build_database(settings: Settings) -> SqlDataStorage:
    return SqlDataStorage(url=settings.database_url)

Câblage — 1 fichier par module

# app/bootstrap/wiring/mail.py
def wire_mail(ctx: ExecutionContext) -> tuple[HttpRouteSpec, ...]:
    repository = MailRepository(session_factory=ctx.database.session)
    return build_mail_v1_routes(
        enqueue_mail_controller=EnqueueMailController(repository=repository),
        read_mail_controller=ReadMailController(repository=repository),
    )

Startup — build_app(ctx) orchestrateur

# app/bootstrap/startup/http_runtime.py
def build_app(*, settings=None, database=None) -> FastAPI:
    cfg = settings or get_settings()
    db = database or build_database(cfg)
    db.prepare()

    ctx = ExecutionContext(
        settings=cfg,
        database=db,
        readiness_checker=ReadinessChecker(probes=[make_db_probe(db)]),
        # …
    )

    app = FastAPI(...)
    app.add_middleware(TracingMiddleware)

    if cfg.service_auth_enabled:
        install_auth_middleware(app, verifier=build_verifier(cfg), ...)

    app.include_router(build_fastapi_router(routes=all_routes(ctx)))
    return app

Pourquoi cette structure

1. Cohérence de l'écosystème

api est le canon historique. Tous les services dérivés (mail-out, audit, search) suivent le même layout — un dev qui passe de l'un à l'autre retrouve immédiatement ses repères.

2. Multi-framework (FastAPI + Litestar)

Le découpage frameworks/{fastapi,litestar}/ permet de servir le même service sur deux frameworks ASGI sans dupliquer la logique métier.

3. Testabilité unitaire pure

Les controllers sont des fonctions pures — testables sans TestClient, sans framework. Le découpage en couches permet de mocker chaque couche indépendamment.

4. Évolutivité

Un service qui passe de 1 à 22 modules métier (comme api) garde la même structure. Pas de réécriture forcée.

Anti-patterns à éviter

❌ Package = src/<service_name>/

Le canon api utilise app/ à la racine. Pas de namespace package par service.

❌ app.py ou routes.py à la racine

Le code de boot est dans bootstrap/startup/http_runtime.py. Le fichier app.py à la racine était une simplification du template à plat — déprécié.

❌ modules/<x>/domain.py mélangeant business + persistence

Le canon impose la séparation controller/ + interface/ + model/. Un fichier domain.py qui contient MailService (avec session DB + business logic + state transitions) est un anti-pattern.

❌ model.py mixant SQLModel + business

SearchableProfile doit être dans model/persistence/searchable_profile_record.py (SearchableProfileRecord), pas dans un model.py à plat à côté du domain.

❌ Routes non versionnées dans modules/<x>/routes.py

Les routes HTTP métier appartiennent à modules/<x>/interface/http/v1/routes.py et s'exposent sous /api/v1. Le fichier routes.py à plat (ou interface/http/routes.py sans v1) est une simplification dépréciée — seul le module system (probes infra) reste non versionné.

❌ Controller qui touche la Session

Le controller/ ne fait pas d'accès DB direct : il valide puis délègue au repository. Un session.add(...) dans un controller mélange cas d'usage et persistance — c'est le rôle de model/repositories/<entity>_repository.py.

Migration depuis le template à plat

Si vous avez un service généré depuis l'ancien template cookiecutter mic-struct (= structure à plat), suivez cette migration :

  1. Renommez src/<service_name>/ → app/.
  2. Créez la structure app/{bootstrap,entrypoints,frameworks, modules/system,platform,shared}/.
  3. Déplacez :
  4. app.py → app/bootstrap/startup/http_runtime.py
  5. settings.py → app/bootstrap/config/settings.py
  6. routes.py → app/bootstrap/startup/http_routes.py
  7. health.py + readiness.py → app/modules/system/controller/
  8. __main__.py → app/entrypoints/http/main.py
  9. Décomposez chaque module métier en 5 couches :
  10. modules/<x>/domain.py → modules/<x>/controller/<action>.py (classe, 1 par action)
    • modules/<x>/model/business/<action>.py (résultat StrictSchema)
  11. modules/<x>/model.py → modules/<x>/model/persistence/<entity>_record.py
    • modules/<x>/model/repositories/<entity>_repository.py (accès persistance)
  12. modules/<x>/routes.py → modules/<x>/interface/http/v1/<action>.py
    • modules/<x>/interface/http/v1/routes.py (routes sous /api/v1)
  13. Ajoutez :
  14. app/bootstrap/providers/<dep>.py pour chaque dépendance externe
  15. app/bootstrap/context/execution_context.py (frozen dataclass)
  16. app/bootstrap/wiring/<module>.py pour chaque module
  17. app/frameworks/fastapi/middlewares/<x>.py pour chaque middleware
  18. Mettez à jour pyproject.toml :
    [tool.hatch.build.targets.wheel]
    packages = ["app"]
    
    [project.scripts]
    <service-name> = "app.entrypoints.http.main:main"
    
    [tool.pytest.ini_options]
    addopts = "--cov=app --cov-branch"
    
  19. Mettez à jour les imports en masse : <service_name>.X → app.X partout.
  20. Gate : make gate doit passer à 100 %.

Exemples de migration : commits 273a3df (mail-out), 81a447c (audit), 9365835 (search).