[{"data":1,"prerenderedAt":1079},["ShallowReactive",2],{"post-en-fastapi-project-architecture":3},{"id":4,"title":5,"body":6,"date":1064,"description":1065,"excerpt":1066,"extension":1067,"meta":1068,"navigation":95,"path":1069,"readTime":110,"seo":1070,"slug":1071,"stem":1072,"tags":1073,"__hash__":1078},"en_blog/en/blog/fastapi-architecture.md","Building a FastAPI Project Structure That Scales and Lasts",{"type":7,"value":8,"toc":1054},"minimark",[9,14,23,28,38,41,45,242,245,249,393,396,400,620,627,631,721,728,732,811,814,818,991,998,1002,1005,1047,1050],[10,11,13],"h1",{"id":12},"structuring-a-fastapi-project-that-lasts-architecture-layers-and-dependencies","Structuring a FastAPI Project That Lasts: Architecture, Layers, and Dependencies",[15,16,17,18,22],"p",{},"Most FastAPI tutorials put everything in ",[19,20,21],"code",{},"main.py",". That works for a demonstration, not for an application maintained by a team over several years. Here is the architecture applied in practice on projects built to last, along with the reasoning behind each decision.",[24,25,27],"h2",{"id":26},"project-structure","Project Structure",[29,30,35],"pre",{"className":31,"code":33,"language":34},[32],"language-text","app/\n├── api/\n│   ├── dependencies.py       # Injected dependencies (auth, db, etc.)\n│   ├── routers/\n│   │   ├── certificates.py\n│   │   └── accounts.py\n│   └── schemas/\n│       ├── certificate.py    # Pydantic models — API input/output\n│       └── account.py\n├── core/\n│   ├── config.py             # Settings (pydantic-settings)\n│   └── security.py           # JWT, hashing, etc.\n├── domain/\n│   ├── models.py             # Dataclasses — internal business objects\n│   └── exceptions.py         # Business exceptions\n├── infrastructure/\n│   ├── database.py           # SQLAlchemy session\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],{},"This structure separates four layers: API (HTTP), domain (business logic), infrastructure (database, external services), and services (orchestration between the two).",[24,42,44],{"id":43},"the-api-layer-routers-and-schemas","The API Layer: Routers and Schemas",[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],{},"The router contains no business logic — only HTTP mapping: request deserialisation, service call, response serialisation, and translation of business exceptions into HTTP status codes.",[24,246,248],{"id":247},"the-service-layer-business-logic","The Service Layer: Business Logic",[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, DuplicateCertificate\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        if payload.volume \u003C= 0:\n            raise InsufficientVolume(f\"Invalid volume: {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(\"A certificate already exists for this period\")\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,269,274,279,283,288,293,298,302,307,312,317,321,326,331,336,341,345,350,355,359,364,368,373,378,383,388],{"__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,268],{},"from app.domain.exceptions import CertificateNotFound, InsufficientVolume, DuplicateCertificate\n",[53,270,271],{"class":55,"line":74},[53,272,273],{},"from app.infrastructure.repositories.certificate_repo import CertificateRepository\n",[53,275,276],{"class":55,"line":80},[53,277,278],{},"from app.api.schemas.certificate import CertificateCreate\n",[53,280,281],{"class":55,"line":86},[53,282,96],{"emptyLinePlaceholder":95},[53,284,285],{"class":55,"line":92},[53,286,287],{},"class CertificateService:\n",[53,289,290],{"class":55,"line":99},[53,291,292],{},"    def __init__(self, repo: CertificateRepository):\n",[53,294,295],{"class":55,"line":105},[53,296,297],{},"        self.repo = repo\n",[53,299,300],{"class":55,"line":110},[53,301,96],{"emptyLinePlaceholder":95},[53,303,304],{"class":55,"line":116},[53,305,306],{},"    async def create(self, payload: CertificateCreate, owner: str) -> Certificate:\n",[53,308,309],{"class":55,"line":122},[53,310,311],{},"        if payload.volume \u003C= 0:\n",[53,313,314],{"class":55,"line":128},[53,315,316],{},"            raise InsufficientVolume(f\"Invalid volume: {payload.volume}\")\n",[53,318,319],{"class":55,"line":134},[53,320,96],{"emptyLinePlaceholder":95},[53,322,323],{"class":55,"line":140},[53,324,325],{},"        existing = await self.repo.find_by_period(\n",[53,327,328],{"class":55,"line":146},[53,329,330],{},"            owner=owner,\n",[53,332,333],{"class":55,"line":152},[53,334,335],{},"            period_from=payload.period_from,\n",[53,337,338],{"class":55,"line":158},[53,339,340],{},"            period_to=payload.period_to\n",[53,342,343],{"class":55,"line":164},[53,344,229],{},[53,346,347],{"class":55,"line":170},[53,348,349],{},"        if existing:\n",[53,351,352],{"class":55,"line":176},[53,353,354],{},"            raise DuplicateCertificate(\"A certificate already exists for this period\")\n",[53,356,357],{"class":55,"line":181},[53,358,96],{"emptyLinePlaceholder":95},[53,360,361],{"class":55,"line":187},[53,362,363],{},"        return await self.repo.create(payload, owner=owner)\n",[53,365,366],{"class":55,"line":193},[53,367,96],{"emptyLinePlaceholder":95},[53,369,370],{"class":55,"line":199},[53,371,372],{},"    async def get_by_id(self, certificate_id: str) -> Certificate:\n",[53,374,375],{"class":55,"line":204},[53,376,377],{},"        certificate = await self.repo.find_by_id(certificate_id)\n",[53,379,380],{"class":55,"line":209},[53,381,382],{},"        if not certificate:\n",[53,384,385],{"class":55,"line":214},[53,386,387],{},"            raise CertificateNotFound(certificate_id)\n",[53,389,390],{"class":55,"line":220},[53,391,392],{},"        return certificate\n",[15,394,395],{},"The service layer is testable without a database or HTTP — mocking the repository is sufficient. This is where the value of the separation is most tangible.",[24,397,399],{"id":398},"the-repository-layer-data-access","The Repository Layer: Data Access",[29,401,403],{"className":47,"code":402,"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        \"\"\"Maps an ORM object to a domain object.\"\"\"\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,404,405,410,415,420,424,429,433,438,443,448,452,457,462,467,471,476,481,486,491,495,500,505,510,515,519,523,528,532,537,542,547,551,555,561,567,573,579,585,591,597,603,609,615],{"__ignoreMap":37},[53,406,407],{"class":55,"line":56},[53,408,409],{},"# app/infrastructure/repositories/certificate_repo.py\n",[53,411,412],{"class":55,"line":62},[53,413,414],{},"from sqlalchemy.ext.asyncio import AsyncSession\n",[53,416,417],{"class":55,"line":68},[53,418,419],{},"from sqlalchemy import select\n",[53,421,422],{"class":55,"line":74},[53,423,263],{},[53,425,426],{"class":55,"line":80},[53,427,428],{},"from app.infrastructure.database import CertificateORM\n",[53,430,431],{"class":55,"line":86},[53,432,96],{"emptyLinePlaceholder":95},[53,434,435],{"class":55,"line":92},[53,436,437],{},"class CertificateRepository:\n",[53,439,440],{"class":55,"line":99},[53,441,442],{},"    def __init__(self, session: AsyncSession):\n",[53,444,445],{"class":55,"line":105},[53,446,447],{},"        self.session = session\n",[53,449,450],{"class":55,"line":110},[53,451,96],{"emptyLinePlaceholder":95},[53,453,454],{"class":55,"line":116},[53,455,456],{},"    async def find_by_id(self, certificate_id: str) -> Certificate | None:\n",[53,458,459],{"class":55,"line":122},[53,460,461],{},"        result = await self.session.execute(\n",[53,463,464],{"class":55,"line":128},[53,465,466],{},"            select(CertificateORM).where(CertificateORM.id == certificate_id)\n",[53,468,469],{"class":55,"line":134},[53,470,229],{},[53,472,473],{"class":55,"line":140},[53,474,475],{},"        orm_obj = result.scalar_one_or_none()\n",[53,477,478],{"class":55,"line":146},[53,479,480],{},"        if orm_obj is None:\n",[53,482,483],{"class":55,"line":152},[53,484,485],{},"            return None\n",[53,487,488],{"class":55,"line":158},[53,489,490],{},"        return self._to_domain(orm_obj)\n",[53,492,493],{"class":55,"line":164},[53,494,96],{"emptyLinePlaceholder":95},[53,496,497],{"class":55,"line":170},[53,498,499],{},"    async def create(self, payload, owner: str) -> Certificate:\n",[53,501,502],{"class":55,"line":176},[53,503,504],{},"        orm_obj = CertificateORM(\n",[53,506,507],{"class":55,"line":181},[53,508,509],{},"            volume=payload.volume,\n",[53,511,512],{"class":55,"line":187},[53,513,514],{},"            technology=payload.technology,\n",[53,516,517],{"class":55,"line":193},[53,518,330],{},[53,520,521],{"class":55,"line":199},[53,522,335],{},[53,524,525],{"class":55,"line":204},[53,526,527],{},"            period_to=payload.period_to,\n",[53,529,530],{"class":55,"line":209},[53,531,229],{},[53,533,534],{"class":55,"line":214},[53,535,536],{},"        self.session.add(orm_obj)\n",[53,538,539],{"class":55,"line":220},[53,540,541],{},"        await self.session.commit()\n",[53,543,544],{"class":55,"line":226},[53,545,546],{},"        await self.session.refresh(orm_obj)\n",[53,548,549],{"class":55,"line":232},[53,550,490],{},[53,552,553],{"class":55,"line":238},[53,554,96],{"emptyLinePlaceholder":95},[53,556,558],{"class":55,"line":557},33,[53,559,560],{},"    def _to_domain(self, orm_obj: CertificateORM) -> Certificate:\n",[53,562,564],{"class":55,"line":563},34,[53,565,566],{},"        \"\"\"Maps an ORM object to a domain object.\"\"\"\n",[53,568,570],{"class":55,"line":569},35,[53,571,572],{},"        return Certificate(\n",[53,574,576],{"class":55,"line":575},36,[53,577,578],{},"            id=str(orm_obj.id),\n",[53,580,582],{"class":55,"line":581},37,[53,583,584],{},"            volume=orm_obj.volume,\n",[53,586,588],{"class":55,"line":587},38,[53,589,590],{},"            technology=orm_obj.technology,\n",[53,592,594],{"class":55,"line":593},39,[53,595,596],{},"            status=orm_obj.status,\n",[53,598,600],{"class":55,"line":599},40,[53,601,602],{},"            owner=orm_obj.owner,\n",[53,604,606],{"class":55,"line":605},41,[53,607,608],{},"            period_from=orm_obj.period_from.isoformat(),\n",[53,610,612],{"class":55,"line":611},42,[53,613,614],{},"            period_to=orm_obj.period_to.isoformat(),\n",[53,616,618],{"class":55,"line":617},43,[53,619,229],{},[15,621,622,623,626],{},"The repository is the only layer that knows about SQLAlchemy. The rest of the codebase works with domain objects (",[19,624,625],{},"Certificate"," dataclass) — not ORM models. This decoupling means switching the ORM or the database does not touch the business logic.",[24,628,630],{"id":629},"dependency-injection","Dependency Injection",[29,632,634],{"className":47,"code":633,"language":49,"meta":37,"style":37},"# app/api/dependencies.py\nfrom fastapi import Depends, Request, HTTPException\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,635,636,641,646,650,655,659,663,667,672,677,682,687,692,696,701,706,711,716],{"__ignoreMap":37},[53,637,638],{"class":55,"line":56},[53,639,640],{},"# app/api/dependencies.py\n",[53,642,643],{"class":55,"line":62},[53,644,645],{},"from fastapi import Depends, Request, HTTPException\n",[53,647,648],{"class":55,"line":68},[53,649,414],{},[53,651,652],{"class":55,"line":74},[53,653,654],{},"from app.infrastructure.database import get_db_session\n",[53,656,657],{"class":55,"line":80},[53,658,273],{},[53,660,661],{"class":55,"line":86},[53,662,83],{},[53,664,665],{"class":55,"line":92},[53,666,96],{"emptyLinePlaceholder":95},[53,668,669],{"class":55,"line":99},[53,670,671],{},"async def get_certificate_service(\n",[53,673,674],{"class":55,"line":105},[53,675,676],{},"    session: AsyncSession = Depends(get_db_session)\n",[53,678,679],{"class":55,"line":110},[53,680,681],{},") -> CertificateService:\n",[53,683,684],{"class":55,"line":116},[53,685,686],{},"    repo = CertificateRepository(session)\n",[53,688,689],{"class":55,"line":122},[53,690,691],{},"    return CertificateService(repo)\n",[53,693,694],{"class":55,"line":128},[53,695,96],{"emptyLinePlaceholder":95},[53,697,698],{"class":55,"line":134},[53,699,700],{},"async def get_current_user(request: Request) -> str:\n",[53,702,703],{"class":55,"line":140},[53,704,705],{},"    session_data = await session_manager.get_session(request)\n",[53,707,708],{"class":55,"line":146},[53,709,710],{},"    if not session_data:\n",[53,712,713],{"class":55,"line":152},[53,714,715],{},"        raise HTTPException(status_code=401)\n",[53,717,718],{"class":55,"line":158},[53,719,720],{},"    return session_data[\"user_id\"]\n",[15,722,723,724,727],{},"FastAPI resolves dependencies automatically and manages their lifecycle. ",[19,725,726],{},"get_db_session"," creates one session per request and closes it cleanly afterwards — even in the event of an exception.",[24,729,731],{"id":730},"business-exceptions","Business Exceptions",[29,733,735],{"className":47,"code":734,"language":49,"meta":37,"style":37},"# app/domain/exceptions.py\n\nclass DomainException(Exception):\n    \"\"\"Base class for all business exceptions.\"\"\"\n    pass\n\nclass CertificateNotFound(DomainException):\n    def __init__(self, certificate_id: str):\n        super().__init__(f\"Certificate '{certificate_id}' not found\")\n        self.certificate_id = certificate_id\n\nclass InsufficientVolume(DomainException):\n    pass\n\nclass DuplicateCertificate(DomainException):\n    pass\n",[19,736,737,742,746,751,756,761,765,770,775,780,785,789,794,798,802,807],{"__ignoreMap":37},[53,738,739],{"class":55,"line":56},[53,740,741],{},"# app/domain/exceptions.py\n",[53,743,744],{"class":55,"line":62},[53,745,96],{"emptyLinePlaceholder":95},[53,747,748],{"class":55,"line":68},[53,749,750],{},"class DomainException(Exception):\n",[53,752,753],{"class":55,"line":74},[53,754,755],{},"    \"\"\"Base class for all business exceptions.\"\"\"\n",[53,757,758],{"class":55,"line":80},[53,759,760],{},"    pass\n",[53,762,763],{"class":55,"line":86},[53,764,96],{"emptyLinePlaceholder":95},[53,766,767],{"class":55,"line":92},[53,768,769],{},"class CertificateNotFound(DomainException):\n",[53,771,772],{"class":55,"line":99},[53,773,774],{},"    def __init__(self, certificate_id: str):\n",[53,776,777],{"class":55,"line":105},[53,778,779],{},"        super().__init__(f\"Certificate '{certificate_id}' not found\")\n",[53,781,782],{"class":55,"line":110},[53,783,784],{},"        self.certificate_id = certificate_id\n",[53,786,787],{"class":55,"line":116},[53,788,96],{"emptyLinePlaceholder":95},[53,790,791],{"class":55,"line":122},[53,792,793],{},"class InsufficientVolume(DomainException):\n",[53,795,796],{"class":55,"line":128},[53,797,760],{},[53,799,800],{"class":55,"line":134},[53,801,96],{"emptyLinePlaceholder":95},[53,803,804],{"class":55,"line":140},[53,805,806],{},"class DuplicateCertificate(DomainException):\n",[53,808,809],{"class":55,"line":146},[53,810,760],{},[15,812,813],{},"Business exceptions do not depend on FastAPI — they express what can go wrong in the domain. Translating them into HTTP status codes is the router's responsibility.",[24,815,817],{"id":816},"testing-services-without-infrastructure","Testing Services Without Infrastructure",[29,819,821],{"className":47,"code":820,"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  # No duplicate by default\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,822,823,828,833,838,842,847,851,855,860,865,870,875,880,884,888,893,898,902,907,912,917,922,927,932,937,942,947,951,956,960,965,969,973,977,981,986],{"__ignoreMap":37},[53,824,825],{"class":55,"line":56},[53,826,827],{},"# tests/services/test_certificate_service.py\n",[53,829,830],{"class":55,"line":62},[53,831,832],{},"import pytest\n",[53,834,835],{"class":55,"line":68},[53,836,837],{},"from unittest.mock import AsyncMock\n",[53,839,840],{"class":55,"line":74},[53,841,83],{},[53,843,844],{"class":55,"line":80},[53,845,846],{},"from app.domain.exceptions import InsufficientVolume\n",[53,848,849],{"class":55,"line":86},[53,850,278],{},[53,852,853],{"class":55,"line":92},[53,854,96],{"emptyLinePlaceholder":95},[53,856,857],{"class":55,"line":99},[53,858,859],{},"@pytest.fixture\n",[53,861,862],{"class":55,"line":105},[53,863,864],{},"def mock_repo():\n",[53,866,867],{"class":55,"line":110},[53,868,869],{},"    repo = AsyncMock()\n",[53,871,872],{"class":55,"line":116},[53,873,874],{},"    repo.find_by_period.return_value = None  # No duplicate by default\n",[53,876,877],{"class":55,"line":122},[53,878,879],{},"    return repo\n",[53,881,882],{"class":55,"line":128},[53,883,96],{"emptyLinePlaceholder":95},[53,885,886],{"class":55,"line":134},[53,887,859],{},[53,889,890],{"class":55,"line":140},[53,891,892],{},"def service(mock_repo):\n",[53,894,895],{"class":55,"line":146},[53,896,897],{},"    return CertificateService(repo=mock_repo)\n",[53,899,900],{"class":55,"line":152},[53,901,96],{"emptyLinePlaceholder":95},[53,903,904],{"class":55,"line":158},[53,905,906],{},"async def test_create_certificate_invalid_volume(service):\n",[53,908,909],{"class":55,"line":164},[53,910,911],{},"    payload = CertificateCreate(\n",[53,913,914],{"class":55,"line":170},[53,915,916],{},"        volume=-10,\n",[53,918,919],{"class":55,"line":176},[53,920,921],{},"        technology=\"WIND\",\n",[53,923,924],{"class":55,"line":181},[53,925,926],{},"        period_from=\"2024-01-01\",\n",[53,928,929],{"class":55,"line":187},[53,930,931],{},"        period_to=\"2024-01-31\"\n",[53,933,934],{"class":55,"line":193},[53,935,936],{},"    )\n",[53,938,939],{"class":55,"line":199},[53,940,941],{},"    with pytest.raises(InsufficientVolume):\n",[53,943,944],{"class":55,"line":204},[53,945,946],{},"        await service.create(payload, owner=\"user-1\")\n",[53,948,949],{"class":55,"line":209},[53,950,96],{"emptyLinePlaceholder":95},[53,952,953],{"class":55,"line":214},[53,954,955],{},"async def test_create_certificate_success(service, mock_repo):\n",[53,957,958],{"class":55,"line":220},[53,959,911],{},[53,961,962],{"class":55,"line":226},[53,963,964],{},"        volume=1500,\n",[53,966,967],{"class":55,"line":232},[53,968,921],{},[53,970,971],{"class":55,"line":238},[53,972,926],{},[53,974,975],{"class":55,"line":557},[53,976,931],{},[53,978,979],{"class":55,"line":563},[53,980,936],{},[53,982,983],{"class":55,"line":569},[53,984,985],{},"    await service.create(payload, owner=\"user-1\")\n",[53,987,988],{"class":55,"line":575},[53,989,990],{},"    mock_repo.create.assert_called_once()\n",[15,992,993,994,997],{},"Service tests are fast, deterministic, and require no database. ",[19,995,996],{},"AsyncMock"," replaces the repository — we are testing the logic, not the infrastructure.",[24,999,1001],{"id":1000},"what-this-architecture-delivers","What This Architecture Delivers",[15,1003,1004],{},"Layered separation is not gratuitous complexity. It addresses concrete problems on a project that must remain maintainable over time:",[1006,1007,1008,1016,1033,1041],"ul",{},[1009,1010,1011,1015],"li",{},[1012,1013,1014],"strong",{},"Testability"," — services test without a database, routers with an HTTP test client",[1009,1017,1018,1021,1022,1025,1026,1029,1030],{},[1012,1019,1020],{},"Readability"," — a new developer knows exactly where to look: business logic in ",[19,1023,1024],{},"services/",", data access in ",[19,1027,1028],{},"repositories/",", HTTP concerns in ",[19,1031,1032],{},"routers/",[1009,1034,1035,1038,1039],{},[1012,1036,1037],{},"Flexibility"," — replacing PostgreSQL with another database only touches ",[19,1040,1028],{},[1009,1042,1043,1046],{},[1012,1044,1045],{},"Separation of concerns"," — an HTTP bug stays in the router, a business bug stays in the service",[15,1048,1049],{},"The trade-off: more files, more layers to traverse for a simple feature. On a one-week throwaway project, it is over-engineered. On a project maintained by a team for months, it is the difference between code that is still comprehensible at the six-month mark and a codebase nobody wants to touch.",[1051,1052,1053],"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":1055},[1056,1057,1058,1059,1060,1061,1062,1063],{"id":26,"depth":62,"text":27},{"id":43,"depth":62,"text":44},{"id":247,"depth":62,"text":248},{"id":398,"depth":62,"text":399},{"id":629,"depth":62,"text":630},{"id":730,"depth":62,"text":731},{"id":816,"depth":62,"text":817},{"id":1000,"depth":62,"text":1001},"2025-03-01","Most FastAPI tutorials put everything in main.py. That works for a demonstration, not for an application maintained by a team over several years. Here is the architecture applied in practice on projects built to last, along with the reasoning behind each decision.",null,"md",{},"/en/blog/fastapi-architecture",{"title":5,"description":1065},"fastapi-project-architecture","en/blog/fastapi-architecture",[1074,1075,1076,1077],"FastAPI","Python","Architecture","Clean Code","Za1f4WtVl6o_dsfLr_oSx1xZ1EKI_PcBK7TQUyq42Ic",1774645635810]