[{"data":1,"prerenderedAt":1083},["ShallowReactive",2],{"post-fr-fastapi-architecture":3},{"id":4,"title":5,"body":6,"date":1068,"description":1069,"excerpt":1070,"extension":1071,"meta":1072,"navigation":95,"path":1073,"readTime":110,"seo":1074,"slug":1075,"stem":1076,"tags":1077,"__hash__":1082},"fr_blog/fr/blog/fastapi-architecture.md","Mettre en place une architecture FastAPI scalable et durable",{"type":7,"value":8,"toc":1058},"minimark",[9,14,23,28,38,41,45,242,245,249,397,400,404,624,631,635,725,732,736,815,818,822,995,1002,1006,1009,1051,1054],[10,11,13],"h1",{"id":12},"structurer-un-projet-fastapi-qui-dure-architecture-couches-et-dépendances","Structurer un projet FastAPI qui dure : architecture, couches et dépendances",[15,16,17,18,22],"p",{},"La plupart des tutoriels FastAPI mettent tout dans ",[19,20,21],"code",{},"main.py",". Ça marche pour un exemple, pas pour une application maintenue par une équipe sur plusieurs années. Voici l'architecture que j'applique sur les projets qui durent, avec les raisons derrière chaque décision.",[24,25,27],"h2",{"id":26},"la-structure-de-projet","La structure de projet",[29,30,35],"pre",{"className":31,"code":33,"language":34},[32],"language-text","app/\n├── api/\n│   ├── dependencies.py       # Dépendances injectées (auth, db, etc.)\n│   ├── routers/\n│   │   ├── certificates.py\n│   │   └── accounts.py\n│   └── schemas/\n│       ├── certificate.py    # Pydantic models — entrée/sortie API\n│       └── account.py\n├── core/\n│   ├── config.py             # Settings (pydantic-settings)\n│   └── security.py           # JWT, hashing, etc.\n├── domain/\n│   ├── models.py             # Dataclasses — objets métier internes\n│   └── exceptions.py         # Exceptions métier\n├── infrastructure/\n│   ├── database.py           # Session SQLAlchemy\n│   └── repositories/\n│       ├── certificate_repo.py\n│       └── account_repo.py\n├── services/\n│   ├── certificate_service.py\n│   └── account_service.py\n└── main.py\n","text",[19,36,33],{"__ignoreMap":37},"",[15,39,40],{},"Cette structure sépare quatre couches : API (HTTP), domaine (logique métier), infrastructure (base de données, services externes), et services (orchestration entre les deux).",[24,42,44],{"id":43},"la-couche-api-routers-et-schémas","La couche API : routers et schémas",[29,46,50],{"className":47,"code":48,"language":49,"meta":37,"style":37},"language-python shiki shiki-themes github-dark github-light","# app/api/routers/certificates.py\nfrom fastapi import APIRouter, Depends, HTTPException, status\nfrom app.api.schemas.certificate import CertificateCreate, CertificateResponse\nfrom app.api.dependencies import get_certificate_service, get_current_user\nfrom app.services.certificate_service import CertificateService\nfrom app.domain.exceptions import CertificateNotFound, InsufficientVolume\n\nrouter = APIRouter(prefix=\"/certificates\", tags=[\"certificates\"])\n\n@router.post(\"/\", response_model=CertificateResponse, status_code=status.HTTP_201_CREATED)\nasync def create_certificate(\n    payload: CertificateCreate,\n    service: CertificateService = Depends(get_certificate_service),\n    current_user: str = Depends(get_current_user),\n):\n    try:\n        certificate = await service.create(payload, owner=current_user)\n        return CertificateResponse.model_validate(certificate)\n    except InsufficientVolume as e:\n        raise HTTPException(status_code=422, detail=str(e))\n\n@router.get(\"/{certificate_id}\", response_model=CertificateResponse)\nasync def get_certificate(\n    certificate_id: str,\n    service: CertificateService = Depends(get_certificate_service),\n):\n    try:\n        return CertificateResponse.model_validate(\n            await service.get_by_id(certificate_id)\n        )\n    except CertificateNotFound:\n        raise HTTPException(status_code=404, detail=\"Certificate not found\")\n","python",[19,51,52,60,66,72,78,84,90,97,103,108,114,120,126,132,138,144,150,156,162,168,174,179,185,191,197,202,207,212,218,224,230,236],{"__ignoreMap":37},[53,54,57],"span",{"class":55,"line":56},"line",1,[53,58,59],{},"# app/api/routers/certificates.py\n",[53,61,63],{"class":55,"line":62},2,[53,64,65],{},"from fastapi import APIRouter, Depends, HTTPException, status\n",[53,67,69],{"class":55,"line":68},3,[53,70,71],{},"from app.api.schemas.certificate import CertificateCreate, CertificateResponse\n",[53,73,75],{"class":55,"line":74},4,[53,76,77],{},"from app.api.dependencies import get_certificate_service, get_current_user\n",[53,79,81],{"class":55,"line":80},5,[53,82,83],{},"from app.services.certificate_service import CertificateService\n",[53,85,87],{"class":55,"line":86},6,[53,88,89],{},"from app.domain.exceptions import CertificateNotFound, InsufficientVolume\n",[53,91,93],{"class":55,"line":92},7,[53,94,96],{"emptyLinePlaceholder":95},true,"\n",[53,98,100],{"class":55,"line":99},8,[53,101,102],{},"router = APIRouter(prefix=\"/certificates\", tags=[\"certificates\"])\n",[53,104,106],{"class":55,"line":105},9,[53,107,96],{"emptyLinePlaceholder":95},[53,109,111],{"class":55,"line":110},10,[53,112,113],{},"@router.post(\"/\", response_model=CertificateResponse, status_code=status.HTTP_201_CREATED)\n",[53,115,117],{"class":55,"line":116},11,[53,118,119],{},"async def create_certificate(\n",[53,121,123],{"class":55,"line":122},12,[53,124,125],{},"    payload: CertificateCreate,\n",[53,127,129],{"class":55,"line":128},13,[53,130,131],{},"    service: CertificateService = Depends(get_certificate_service),\n",[53,133,135],{"class":55,"line":134},14,[53,136,137],{},"    current_user: str = Depends(get_current_user),\n",[53,139,141],{"class":55,"line":140},15,[53,142,143],{},"):\n",[53,145,147],{"class":55,"line":146},16,[53,148,149],{},"    try:\n",[53,151,153],{"class":55,"line":152},17,[53,154,155],{},"        certificate = await service.create(payload, owner=current_user)\n",[53,157,159],{"class":55,"line":158},18,[53,160,161],{},"        return CertificateResponse.model_validate(certificate)\n",[53,163,165],{"class":55,"line":164},19,[53,166,167],{},"    except InsufficientVolume as e:\n",[53,169,171],{"class":55,"line":170},20,[53,172,173],{},"        raise HTTPException(status_code=422, detail=str(e))\n",[53,175,177],{"class":55,"line":176},21,[53,178,96],{"emptyLinePlaceholder":95},[53,180,182],{"class":55,"line":181},22,[53,183,184],{},"@router.get(\"/{certificate_id}\", response_model=CertificateResponse)\n",[53,186,188],{"class":55,"line":187},23,[53,189,190],{},"async def get_certificate(\n",[53,192,194],{"class":55,"line":193},24,[53,195,196],{},"    certificate_id: str,\n",[53,198,200],{"class":55,"line":199},25,[53,201,131],{},[53,203,205],{"class":55,"line":204},26,[53,206,143],{},[53,208,210],{"class":55,"line":209},27,[53,211,149],{},[53,213,215],{"class":55,"line":214},28,[53,216,217],{},"        return CertificateResponse.model_validate(\n",[53,219,221],{"class":55,"line":220},29,[53,222,223],{},"            await service.get_by_id(certificate_id)\n",[53,225,227],{"class":55,"line":226},30,[53,228,229],{},"        )\n",[53,231,233],{"class":55,"line":232},31,[53,234,235],{},"    except CertificateNotFound:\n",[53,237,239],{"class":55,"line":238},32,[53,240,241],{},"        raise HTTPException(status_code=404, detail=\"Certificate not found\")\n",[15,243,244],{},"Le router ne contient aucune logique métier — seulement du mapping HTTP : désérialisation de la requête, appel au service, sérialisation de la réponse, et conversion des exceptions métier en codes HTTP.",[24,246,248],{"id":247},"la-couche-service-logique-métier","La couche service : logique métier",[29,250,252],{"className":47,"code":251,"language":49,"meta":37,"style":37},"# app/services/certificate_service.py\nfrom app.domain.models import Certificate\nfrom app.domain.exceptions import CertificateNotFound, InsufficientVolume\nfrom app.infrastructure.repositories.certificate_repo import CertificateRepository\nfrom app.api.schemas.certificate import CertificateCreate\n\nclass CertificateService:\n    def __init__(self, repo: CertificateRepository):\n        self.repo = repo\n\n    async def create(self, payload: CertificateCreate, owner: str) -> Certificate:\n        # Logique métier : vérifications avant création\n        if payload.volume \u003C= 0:\n            raise InsufficientVolume(f\"Volume invalide : {payload.volume}\")\n\n        existing = await self.repo.find_by_period(\n            owner=owner,\n            period_from=payload.period_from,\n            period_to=payload.period_to\n        )\n        if existing:\n            raise DuplicateCertificate(f\"Certificat existant pour cette période\")\n\n        return await self.repo.create(payload, owner=owner)\n\n    async def get_by_id(self, certificate_id: str) -> Certificate:\n        certificate = await self.repo.find_by_id(certificate_id)\n        if not certificate:\n            raise CertificateNotFound(certificate_id)\n        return certificate\n",[19,253,254,259,264,268,273,278,282,287,292,297,301,306,311,316,321,325,330,335,340,345,349,354,359,363,368,372,377,382,387,392],{"__ignoreMap":37},[53,255,256],{"class":55,"line":56},[53,257,258],{},"# app/services/certificate_service.py\n",[53,260,261],{"class":55,"line":62},[53,262,263],{},"from app.domain.models import Certificate\n",[53,265,266],{"class":55,"line":68},[53,267,89],{},[53,269,270],{"class":55,"line":74},[53,271,272],{},"from app.infrastructure.repositories.certificate_repo import CertificateRepository\n",[53,274,275],{"class":55,"line":80},[53,276,277],{},"from app.api.schemas.certificate import CertificateCreate\n",[53,279,280],{"class":55,"line":86},[53,281,96],{"emptyLinePlaceholder":95},[53,283,284],{"class":55,"line":92},[53,285,286],{},"class CertificateService:\n",[53,288,289],{"class":55,"line":99},[53,290,291],{},"    def __init__(self, repo: CertificateRepository):\n",[53,293,294],{"class":55,"line":105},[53,295,296],{},"        self.repo = repo\n",[53,298,299],{"class":55,"line":110},[53,300,96],{"emptyLinePlaceholder":95},[53,302,303],{"class":55,"line":116},[53,304,305],{},"    async def create(self, payload: CertificateCreate, owner: str) -> Certificate:\n",[53,307,308],{"class":55,"line":122},[53,309,310],{},"        # Logique métier : vérifications avant création\n",[53,312,313],{"class":55,"line":128},[53,314,315],{},"        if payload.volume \u003C= 0:\n",[53,317,318],{"class":55,"line":134},[53,319,320],{},"            raise InsufficientVolume(f\"Volume invalide : {payload.volume}\")\n",[53,322,323],{"class":55,"line":140},[53,324,96],{"emptyLinePlaceholder":95},[53,326,327],{"class":55,"line":146},[53,328,329],{},"        existing = await self.repo.find_by_period(\n",[53,331,332],{"class":55,"line":152},[53,333,334],{},"            owner=owner,\n",[53,336,337],{"class":55,"line":158},[53,338,339],{},"            period_from=payload.period_from,\n",[53,341,342],{"class":55,"line":164},[53,343,344],{},"            period_to=payload.period_to\n",[53,346,347],{"class":55,"line":170},[53,348,229],{},[53,350,351],{"class":55,"line":176},[53,352,353],{},"        if existing:\n",[53,355,356],{"class":55,"line":181},[53,357,358],{},"            raise DuplicateCertificate(f\"Certificat existant pour cette période\")\n",[53,360,361],{"class":55,"line":187},[53,362,96],{"emptyLinePlaceholder":95},[53,364,365],{"class":55,"line":193},[53,366,367],{},"        return await self.repo.create(payload, owner=owner)\n",[53,369,370],{"class":55,"line":199},[53,371,96],{"emptyLinePlaceholder":95},[53,373,374],{"class":55,"line":204},[53,375,376],{},"    async def get_by_id(self, certificate_id: str) -> Certificate:\n",[53,378,379],{"class":55,"line":209},[53,380,381],{},"        certificate = await self.repo.find_by_id(certificate_id)\n",[53,383,384],{"class":55,"line":214},[53,385,386],{},"        if not certificate:\n",[53,388,389],{"class":55,"line":220},[53,390,391],{},"            raise CertificateNotFound(certificate_id)\n",[53,393,394],{"class":55,"line":226},[53,395,396],{},"        return certificate\n",[15,398,399],{},"La couche service est testable sans base de données ni HTTP — il suffit de mocker le repository. C'est là que réside la valeur de cette séparation.",[24,401,403],{"id":402},"la-couche-repository-accès-aux-données","La couche repository : accès aux données",[29,405,407],{"className":47,"code":406,"language":49,"meta":37,"style":37},"# app/infrastructure/repositories/certificate_repo.py\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy import select\nfrom app.domain.models import Certificate\nfrom app.infrastructure.database import CertificateORM\n\nclass CertificateRepository:\n    def __init__(self, session: AsyncSession):\n        self.session = session\n\n    async def find_by_id(self, certificate_id: str) -> Certificate | None:\n        result = await self.session.execute(\n            select(CertificateORM).where(CertificateORM.id == certificate_id)\n        )\n        orm_obj = result.scalar_one_or_none()\n        if orm_obj is None:\n            return None\n        return self._to_domain(orm_obj)\n\n    async def create(self, payload, owner: str) -> Certificate:\n        orm_obj = CertificateORM(\n            volume=payload.volume,\n            technology=payload.technology,\n            owner=owner,\n            period_from=payload.period_from,\n            period_to=payload.period_to,\n        )\n        self.session.add(orm_obj)\n        await self.session.commit()\n        await self.session.refresh(orm_obj)\n        return self._to_domain(orm_obj)\n\n    def _to_domain(self, orm_obj: CertificateORM) -> Certificate:\n        \"\"\"Convertit un objet ORM en objet domaine.\"\"\"\n        return Certificate(\n            id=str(orm_obj.id),\n            volume=orm_obj.volume,\n            technology=orm_obj.technology,\n            status=orm_obj.status,\n            owner=orm_obj.owner,\n            period_from=orm_obj.period_from.isoformat(),\n            period_to=orm_obj.period_to.isoformat(),\n        )\n",[19,408,409,414,419,424,428,433,437,442,447,452,456,461,466,471,475,480,485,490,495,499,504,509,514,519,523,527,532,536,541,546,551,555,559,565,571,577,583,589,595,601,607,613,619],{"__ignoreMap":37},[53,410,411],{"class":55,"line":56},[53,412,413],{},"# app/infrastructure/repositories/certificate_repo.py\n",[53,415,416],{"class":55,"line":62},[53,417,418],{},"from sqlalchemy.ext.asyncio import AsyncSession\n",[53,420,421],{"class":55,"line":68},[53,422,423],{},"from sqlalchemy import select\n",[53,425,426],{"class":55,"line":74},[53,427,263],{},[53,429,430],{"class":55,"line":80},[53,431,432],{},"from app.infrastructure.database import CertificateORM\n",[53,434,435],{"class":55,"line":86},[53,436,96],{"emptyLinePlaceholder":95},[53,438,439],{"class":55,"line":92},[53,440,441],{},"class CertificateRepository:\n",[53,443,444],{"class":55,"line":99},[53,445,446],{},"    def __init__(self, session: AsyncSession):\n",[53,448,449],{"class":55,"line":105},[53,450,451],{},"        self.session = session\n",[53,453,454],{"class":55,"line":110},[53,455,96],{"emptyLinePlaceholder":95},[53,457,458],{"class":55,"line":116},[53,459,460],{},"    async def find_by_id(self, certificate_id: str) -> Certificate | None:\n",[53,462,463],{"class":55,"line":122},[53,464,465],{},"        result = await self.session.execute(\n",[53,467,468],{"class":55,"line":128},[53,469,470],{},"            select(CertificateORM).where(CertificateORM.id == certificate_id)\n",[53,472,473],{"class":55,"line":134},[53,474,229],{},[53,476,477],{"class":55,"line":140},[53,478,479],{},"        orm_obj = result.scalar_one_or_none()\n",[53,481,482],{"class":55,"line":146},[53,483,484],{},"        if orm_obj is None:\n",[53,486,487],{"class":55,"line":152},[53,488,489],{},"            return None\n",[53,491,492],{"class":55,"line":158},[53,493,494],{},"        return self._to_domain(orm_obj)\n",[53,496,497],{"class":55,"line":164},[53,498,96],{"emptyLinePlaceholder":95},[53,500,501],{"class":55,"line":170},[53,502,503],{},"    async def create(self, payload, owner: str) -> Certificate:\n",[53,505,506],{"class":55,"line":176},[53,507,508],{},"        orm_obj = CertificateORM(\n",[53,510,511],{"class":55,"line":181},[53,512,513],{},"            volume=payload.volume,\n",[53,515,516],{"class":55,"line":187},[53,517,518],{},"            technology=payload.technology,\n",[53,520,521],{"class":55,"line":193},[53,522,334],{},[53,524,525],{"class":55,"line":199},[53,526,339],{},[53,528,529],{"class":55,"line":204},[53,530,531],{},"            period_to=payload.period_to,\n",[53,533,534],{"class":55,"line":209},[53,535,229],{},[53,537,538],{"class":55,"line":214},[53,539,540],{},"        self.session.add(orm_obj)\n",[53,542,543],{"class":55,"line":220},[53,544,545],{},"        await self.session.commit()\n",[53,547,548],{"class":55,"line":226},[53,549,550],{},"        await self.session.refresh(orm_obj)\n",[53,552,553],{"class":55,"line":232},[53,554,494],{},[53,556,557],{"class":55,"line":238},[53,558,96],{"emptyLinePlaceholder":95},[53,560,562],{"class":55,"line":561},33,[53,563,564],{},"    def _to_domain(self, orm_obj: CertificateORM) -> Certificate:\n",[53,566,568],{"class":55,"line":567},34,[53,569,570],{},"        \"\"\"Convertit un objet ORM en objet domaine.\"\"\"\n",[53,572,574],{"class":55,"line":573},35,[53,575,576],{},"        return Certificate(\n",[53,578,580],{"class":55,"line":579},36,[53,581,582],{},"            id=str(orm_obj.id),\n",[53,584,586],{"class":55,"line":585},37,[53,587,588],{},"            volume=orm_obj.volume,\n",[53,590,592],{"class":55,"line":591},38,[53,593,594],{},"            technology=orm_obj.technology,\n",[53,596,598],{"class":55,"line":597},39,[53,599,600],{},"            status=orm_obj.status,\n",[53,602,604],{"class":55,"line":603},40,[53,605,606],{},"            owner=orm_obj.owner,\n",[53,608,610],{"class":55,"line":609},41,[53,611,612],{},"            period_from=orm_obj.period_from.isoformat(),\n",[53,614,616],{"class":55,"line":615},42,[53,617,618],{},"            period_to=orm_obj.period_to.isoformat(),\n",[53,620,622],{"class":55,"line":621},43,[53,623,229],{},[15,625,626,627,630],{},"Le repository est la seule couche qui connaît SQLAlchemy. Le reste du code travaille avec des objets domaine (",[19,628,629],{},"Certificate"," dataclass) — pas avec des modèles ORM. Ce découplage permet de changer l'ORM ou la base de données sans toucher à la logique métier.",[24,632,634],{"id":633},"linjection-de-dépendances","L'injection de dépendances",[29,636,638],{"className":47,"code":637,"language":49,"meta":37,"style":37},"# app/api/dependencies.py\nfrom fastapi import Depends, Request\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom app.infrastructure.database import get_db_session\nfrom app.infrastructure.repositories.certificate_repo import CertificateRepository\nfrom app.services.certificate_service import CertificateService\n\nasync def get_certificate_service(\n    session: AsyncSession = Depends(get_db_session)\n) -> CertificateService:\n    repo = CertificateRepository(session)\n    return CertificateService(repo)\n\nasync def get_current_user(request: Request) -> str:\n    session_data = await session_manager.get_session(request)\n    if not session_data:\n        raise HTTPException(status_code=401)\n    return session_data[\"user_id\"]\n",[19,639,640,645,650,654,659,663,667,671,676,681,686,691,696,700,705,710,715,720],{"__ignoreMap":37},[53,641,642],{"class":55,"line":56},[53,643,644],{},"# app/api/dependencies.py\n",[53,646,647],{"class":55,"line":62},[53,648,649],{},"from fastapi import Depends, Request\n",[53,651,652],{"class":55,"line":68},[53,653,418],{},[53,655,656],{"class":55,"line":74},[53,657,658],{},"from app.infrastructure.database import get_db_session\n",[53,660,661],{"class":55,"line":80},[53,662,272],{},[53,664,665],{"class":55,"line":86},[53,666,83],{},[53,668,669],{"class":55,"line":92},[53,670,96],{"emptyLinePlaceholder":95},[53,672,673],{"class":55,"line":99},[53,674,675],{},"async def get_certificate_service(\n",[53,677,678],{"class":55,"line":105},[53,679,680],{},"    session: AsyncSession = Depends(get_db_session)\n",[53,682,683],{"class":55,"line":110},[53,684,685],{},") -> CertificateService:\n",[53,687,688],{"class":55,"line":116},[53,689,690],{},"    repo = CertificateRepository(session)\n",[53,692,693],{"class":55,"line":122},[53,694,695],{},"    return CertificateService(repo)\n",[53,697,698],{"class":55,"line":128},[53,699,96],{"emptyLinePlaceholder":95},[53,701,702],{"class":55,"line":134},[53,703,704],{},"async def get_current_user(request: Request) -> str:\n",[53,706,707],{"class":55,"line":140},[53,708,709],{},"    session_data = await session_manager.get_session(request)\n",[53,711,712],{"class":55,"line":146},[53,713,714],{},"    if not session_data:\n",[53,716,717],{"class":55,"line":152},[53,718,719],{},"        raise HTTPException(status_code=401)\n",[53,721,722],{"class":55,"line":158},[53,723,724],{},"    return session_data[\"user_id\"]\n",[15,726,727,728,731],{},"FastAPI résout les dépendances automatiquement et gère leur cycle de vie. ",[19,729,730],{},"get_db_session"," crée une session par requête et la ferme proprement après — même en cas d'exception.",[24,733,735],{"id":734},"les-exceptions-métier","Les exceptions métier",[29,737,739],{"className":47,"code":738,"language":49,"meta":37,"style":37},"# app/domain/exceptions.py\n\nclass DomainException(Exception):\n    \"\"\"Base class pour toutes les exceptions métier.\"\"\"\n    pass\n\nclass CertificateNotFound(DomainException):\n    def __init__(self, certificate_id: str):\n        super().__init__(f\"Certificat '{certificate_id}' introuvable\")\n        self.certificate_id = certificate_id\n\nclass InsufficientVolume(DomainException):\n    pass\n\nclass DuplicateCertificate(DomainException):\n    pass\n",[19,740,741,746,750,755,760,765,769,774,779,784,789,793,798,802,806,811],{"__ignoreMap":37},[53,742,743],{"class":55,"line":56},[53,744,745],{},"# app/domain/exceptions.py\n",[53,747,748],{"class":55,"line":62},[53,749,96],{"emptyLinePlaceholder":95},[53,751,752],{"class":55,"line":68},[53,753,754],{},"class DomainException(Exception):\n",[53,756,757],{"class":55,"line":74},[53,758,759],{},"    \"\"\"Base class pour toutes les exceptions métier.\"\"\"\n",[53,761,762],{"class":55,"line":80},[53,763,764],{},"    pass\n",[53,766,767],{"class":55,"line":86},[53,768,96],{"emptyLinePlaceholder":95},[53,770,771],{"class":55,"line":92},[53,772,773],{},"class CertificateNotFound(DomainException):\n",[53,775,776],{"class":55,"line":99},[53,777,778],{},"    def __init__(self, certificate_id: str):\n",[53,780,781],{"class":55,"line":105},[53,782,783],{},"        super().__init__(f\"Certificat '{certificate_id}' introuvable\")\n",[53,785,786],{"class":55,"line":110},[53,787,788],{},"        self.certificate_id = certificate_id\n",[53,790,791],{"class":55,"line":116},[53,792,96],{"emptyLinePlaceholder":95},[53,794,795],{"class":55,"line":122},[53,796,797],{},"class InsufficientVolume(DomainException):\n",[53,799,800],{"class":55,"line":128},[53,801,764],{},[53,803,804],{"class":55,"line":134},[53,805,96],{"emptyLinePlaceholder":95},[53,807,808],{"class":55,"line":140},[53,809,810],{},"class DuplicateCertificate(DomainException):\n",[53,812,813],{"class":55,"line":146},[53,814,764],{},[15,816,817],{},"Les exceptions métier ne dépendent pas de FastAPI — elles expriment ce qui peut mal tourner dans le domaine. La conversion en codes HTTP appartient au router.",[24,819,821],{"id":820},"tester-les-services-sans-infrastructure","Tester les services sans infrastructure",[29,823,825],{"className":47,"code":824,"language":49,"meta":37,"style":37},"# tests/services/test_certificate_service.py\nimport pytest\nfrom unittest.mock import AsyncMock\nfrom app.services.certificate_service import CertificateService\nfrom app.domain.exceptions import InsufficientVolume\nfrom app.api.schemas.certificate import CertificateCreate\n\n@pytest.fixture\ndef mock_repo():\n    repo = AsyncMock()\n    repo.find_by_period.return_value = None  # Pas de doublon par défaut\n    return repo\n\n@pytest.fixture\ndef service(mock_repo):\n    return CertificateService(repo=mock_repo)\n\nasync def test_create_certificate_invalid_volume(service):\n    payload = CertificateCreate(\n        volume=-10,\n        technology=\"WIND\",\n        period_from=\"2024-01-01\",\n        period_to=\"2024-01-31\"\n    )\n    with pytest.raises(InsufficientVolume):\n        await service.create(payload, owner=\"user-1\")\n\nasync def test_create_certificate_success(service, mock_repo):\n    payload = CertificateCreate(\n        volume=1500,\n        technology=\"WIND\",\n        period_from=\"2024-01-01\",\n        period_to=\"2024-01-31\"\n    )\n    await service.create(payload, owner=\"user-1\")\n    mock_repo.create.assert_called_once()\n",[19,826,827,832,837,842,846,851,855,859,864,869,874,879,884,888,892,897,902,906,911,916,921,926,931,936,941,946,951,955,960,964,969,973,977,981,985,990],{"__ignoreMap":37},[53,828,829],{"class":55,"line":56},[53,830,831],{},"# tests/services/test_certificate_service.py\n",[53,833,834],{"class":55,"line":62},[53,835,836],{},"import pytest\n",[53,838,839],{"class":55,"line":68},[53,840,841],{},"from unittest.mock import AsyncMock\n",[53,843,844],{"class":55,"line":74},[53,845,83],{},[53,847,848],{"class":55,"line":80},[53,849,850],{},"from app.domain.exceptions import InsufficientVolume\n",[53,852,853],{"class":55,"line":86},[53,854,277],{},[53,856,857],{"class":55,"line":92},[53,858,96],{"emptyLinePlaceholder":95},[53,860,861],{"class":55,"line":99},[53,862,863],{},"@pytest.fixture\n",[53,865,866],{"class":55,"line":105},[53,867,868],{},"def mock_repo():\n",[53,870,871],{"class":55,"line":110},[53,872,873],{},"    repo = AsyncMock()\n",[53,875,876],{"class":55,"line":116},[53,877,878],{},"    repo.find_by_period.return_value = None  # Pas de doublon par défaut\n",[53,880,881],{"class":55,"line":122},[53,882,883],{},"    return repo\n",[53,885,886],{"class":55,"line":128},[53,887,96],{"emptyLinePlaceholder":95},[53,889,890],{"class":55,"line":134},[53,891,863],{},[53,893,894],{"class":55,"line":140},[53,895,896],{},"def service(mock_repo):\n",[53,898,899],{"class":55,"line":146},[53,900,901],{},"    return CertificateService(repo=mock_repo)\n",[53,903,904],{"class":55,"line":152},[53,905,96],{"emptyLinePlaceholder":95},[53,907,908],{"class":55,"line":158},[53,909,910],{},"async def test_create_certificate_invalid_volume(service):\n",[53,912,913],{"class":55,"line":164},[53,914,915],{},"    payload = CertificateCreate(\n",[53,917,918],{"class":55,"line":170},[53,919,920],{},"        volume=-10,\n",[53,922,923],{"class":55,"line":176},[53,924,925],{},"        technology=\"WIND\",\n",[53,927,928],{"class":55,"line":181},[53,929,930],{},"        period_from=\"2024-01-01\",\n",[53,932,933],{"class":55,"line":187},[53,934,935],{},"        period_to=\"2024-01-31\"\n",[53,937,938],{"class":55,"line":193},[53,939,940],{},"    )\n",[53,942,943],{"class":55,"line":199},[53,944,945],{},"    with pytest.raises(InsufficientVolume):\n",[53,947,948],{"class":55,"line":204},[53,949,950],{},"        await service.create(payload, owner=\"user-1\")\n",[53,952,953],{"class":55,"line":209},[53,954,96],{"emptyLinePlaceholder":95},[53,956,957],{"class":55,"line":214},[53,958,959],{},"async def test_create_certificate_success(service, mock_repo):\n",[53,961,962],{"class":55,"line":220},[53,963,915],{},[53,965,966],{"class":55,"line":226},[53,967,968],{},"        volume=1500,\n",[53,970,971],{"class":55,"line":232},[53,972,925],{},[53,974,975],{"class":55,"line":238},[53,976,930],{},[53,978,979],{"class":55,"line":561},[53,980,935],{},[53,982,983],{"class":55,"line":567},[53,984,940],{},[53,986,987],{"class":55,"line":573},[53,988,989],{},"    await service.create(payload, owner=\"user-1\")\n",[53,991,992],{"class":55,"line":579},[53,993,994],{},"    mock_repo.create.assert_called_once()\n",[15,996,997,998,1001],{},"Les tests de service sont rapides, déterministes, et ne nécessitent pas de base de données. L'",[19,999,1000],{},"AsyncMock"," remplace le repository — on teste la logique, pas l'infrastructure.",[24,1003,1005],{"id":1004},"ce-que-cette-architecture-apporte","Ce que cette architecture apporte",[15,1007,1008],{},"La séparation en couches n'est pas de la complexité gratuite. Elle répond à des problèmes concrets sur un projet qui dure :",[1010,1011,1012,1020,1037,1045],"ul",{},[1013,1014,1015,1019],"li",{},[1016,1017,1018],"strong",{},"Testabilité"," — les services se testent sans base de données, les routers avec un client HTTP",[1013,1021,1022,1025,1026,1029,1030,1033,1034],{},[1016,1023,1024],{},"Lisibilité"," — un nouveau développeur sait où chercher : logique métier dans ",[19,1027,1028],{},"services/",", accès aux données dans ",[19,1031,1032],{},"repositories/",", HTTP dans ",[19,1035,1036],{},"routers/",[1013,1038,1039,1042,1043],{},[1016,1040,1041],{},"Évolutivité"," — remplacer PostgreSQL par une autre base de données ne touche que ",[19,1044,1032],{},[1013,1046,1047,1050],{},[1016,1048,1049],{},"Séparation des responsabilités"," — un bug HTTP reste dans le router, un bug métier reste dans le service",[15,1052,1053],{},"Le coût : plus de fichiers, plus de couches à traverser pour une feature simple. Sur un projet d'une semaine jetée, c'est surdimensionné. Sur un projet maintenu par une équipe pendant des mois, c'est la différence entre un code qu'on comprend encore à six mois et un spaghetti qu'on a peur de toucher.",[1055,1056,1057],"style",{},"html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":37,"searchDepth":62,"depth":62,"links":1059},[1060,1061,1062,1063,1064,1065,1066,1067],{"id":26,"depth":62,"text":27},{"id":43,"depth":62,"text":44},{"id":247,"depth":62,"text":248},{"id":402,"depth":62,"text":403},{"id":633,"depth":62,"text":634},{"id":734,"depth":62,"text":735},{"id":820,"depth":62,"text":821},{"id":1004,"depth":62,"text":1005},"2025-03-01","La plupart des tutoriels FastAPI mettent tout dans main.py. Ça marche pour un exemple, pas pour une application maintenue par une équipe sur plusieurs années. Voici l'architecture que j'applique sur les projets qui durent, avec les raisons derrière chaque décision.",null,"md",{},"/fr/blog/fastapi-architecture",{"title":5,"description":1069},"fastapi-architecture","fr/blog/fastapi-architecture",[1078,1079,1080,1081],"FastAPI","Python","Architecture","Clean Code","z8dK5x6KGvAMlIGdoOvcL75JcWyUMUPu9uZZvYhs044",1774645635860]