Structure canonique d'un service mic-struct¶
TL;DR : tout service Python suit la structure
app/{bootstrap,entrypoints,frameworks,modules,platform,shared}/identique à celle derdlink-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 deSessionen direct.interface/http/v1/— traduction HTTP ↔ controller, versionnée/api/v1: un fichier par action (<action>.py= payload/responseStrictSchema+handle_<action>) +routes.py(build_<module>_v1_routes(...)→HttpRouteSpecframework-agnostiques). Les mêmes controllers se rebranchent derrièreinterface/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: SQLModeltable=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 laSessionet renvoie des schémasbusiness(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 :
- Renommez
src/<service_name>/→app/. - Créez la structure
app/{bootstrap,entrypoints,frameworks, modules/system,platform,shared}/. - Déplacez :
app.py→app/bootstrap/startup/http_runtime.pysettings.py→app/bootstrap/config/settings.pyroutes.py→app/bootstrap/startup/http_routes.pyhealth.py+readiness.py→app/modules/system/controller/__main__.py→app/entrypoints/http/main.py- Décomposez chaque module métier en 5 couches :
modules/<x>/domain.py→modules/<x>/controller/<action>.py(classe, 1 par action)modules/<x>/model/business/<action>.py(résultatStrictSchema)
modules/<x>/model.py→modules/<x>/model/persistence/<entity>_record.pymodules/<x>/model/repositories/<entity>_repository.py(accès persistance)
modules/<x>/routes.py→modules/<x>/interface/http/v1/<action>.pymodules/<x>/interface/http/v1/routes.py(routes sous/api/v1)
- Ajoutez :
app/bootstrap/providers/<dep>.pypour chaque dépendance externeapp/bootstrap/context/execution_context.py(frozen dataclass)app/bootstrap/wiring/<module>.pypour chaque moduleapp/frameworks/fastapi/middlewares/<x>.pypour chaque middleware- Mettez à jour
pyproject.toml: - Mettez à jour les imports en masse :
<service_name>.X→app.Xpartout. - Gate :
make gatedoit passer à 100 %.
Exemples de migration : commits
273a3df (mail-out),
81a447c (audit),
9365835 (search).