feat: adding new skills, including testing patterns and methodologies, along with bundled resources for better usability.

This commit is contained in:
duthaho
2026-03-30 12:18:00 +07:00
parent 0ff5ae4082
commit 7fa9a48c6c
89 changed files with 25808 additions and 923 deletions
+638 -47
View File
@@ -1,89 +1,680 @@
---
name: fastapi
description: >
Use this skill when building REST APIs with Python and FastAPI, creating async web applications, or generating OpenAPI/Swagger documentation. Trigger for any mention of FastAPI, Pydantic models, async Python endpoints, dependency injection in Python APIs, or APIRouter patterns. Also applies when setting up Python microservices, adding request validation with Pydantic, or configuring ASGI applications.
---
# FastAPI
## Description
FastAPI web framework with async patterns, Pydantic validation, and OpenAPI documentation.
## When to Use
- Building REST APIs with Python
- Async web applications
- OpenAPI/Swagger documentation needed
- Python microservices
- WebSocket real-time applications
## When NOT to Use
- Django projects — use the `frameworks/django` skill instead
- JavaScript/Node.js backends (Express, NestJS) — this skill is Python-only
- Non-API applications such as CLI tools, desktop apps, or batch processing scripts
---
## Core Patterns
### Route Definition
### 1. Project Structure
Recommended layout for medium-large FastAPI applications:
```
project/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app creation, startup/shutdown
│ ├── config.py # Settings via pydantic-settings
│ ├── dependencies.py # Shared dependencies
│ ├── exceptions.py # Custom exception handlers
│ ├── middleware.py # Custom middleware
│ ├── api/
│ │ ├── __init__.py
│ │ ├── router.py # Root router aggregating all sub-routers
│ │ ├── v1/
│ │ │ ├── __init__.py
│ │ │ ├── users.py # /api/v1/users endpoints
│ │ │ ├── items.py # /api/v1/items endpoints
│ │ │ └── auth.py # /api/v1/auth endpoints
│ │ └── v2/ # Future API version
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py # SQLAlchemy / SQLModel ORM models
│ │ └── item.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py # Pydantic request/response schemas
│ │ └── item.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── user_service.py # Business logic layer
│ │ └── item_service.py
│ ├── repositories/
│ │ ├── __init__.py
│ │ ├── user_repo.py # Data access layer
│ │ └── item_repo.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── database.py # DB engine, session factory
│ │ └── security.py # JWT, hashing, auth utils
│ └── tests/
│ ├── __init__.py
│ ├── conftest.py # Fixtures: test client, test DB
│ ├── test_users.py
│ └── test_items.py
├── alembic/ # Database migrations
│ ├── env.py
│ └── versions/
├── alembic.ini
├── pyproject.toml
├── Dockerfile
└── .env
```
**Key conventions:**
- Separate `schemas/` (Pydantic) from `models/` (ORM) to keep concerns clean
- Use `services/` for business logic, `repositories/` for data access
- Version API routes under `api/v1/`, `api/v2/` for backward compatibility
- Keep `main.py` thin — it only wires things together
```python
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.api.router import api_router
from app.config import settings
from app.core.database import engine
from app.middleware import add_middleware
app = FastAPI()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create tables, warm caches, connect to services
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown: close connections, flush buffers
await engine.dispose()
class UserCreate(BaseModel):
email: str
name: str
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
lifespan=lifespan,
)
add_middleware(app)
app.include_router(api_router, prefix="/api")
```
class UserResponse(BaseModel):
id: int
email: str
name: str
### 2. Route Patterns
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate):
# Create user logic
return UserResponse(id=1, **user.model_dump())
#### APIRouter with tags, prefixes, and dependencies
@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
user = await get_user_by_id(user_id)
```python
from fastapi import APIRouter, Depends, Query, Path, Body, HTTPException, status
from app.schemas.user import UserCreate, UserResponse, UserUpdate, UserList
from app.dependencies import get_current_user
router = APIRouter(
prefix="/users",
tags=["users"],
dependencies=[Depends(get_current_user)], # Applied to all routes
responses={401: {"description": "Not authenticated"}},
)
# Path parameters with validation
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int = Path(..., gt=0, description="The ID of the user to retrieve"),
):
user = await user_service.get(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
# Query parameters with defaults and validation
@router.get("/", response_model=UserList)
async def list_users(
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(20, ge=1, le=100, description="Max records to return"),
search: str | None = Query(None, min_length=1, max_length=100),
sort_by: str = Query("created_at", pattern="^(created_at|name|email)$"),
):
users = await user_service.list(skip=skip, limit=limit, search=search)
return users
# Request body with status codes
@router.post(
"/",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new user",
description="Creates a user account and sends a welcome email.",
)
async def create_user(user: UserCreate = Body(...)):
return await user_service.create(user)
# Multiple response models for different status codes
@router.put("/{user_id}", response_model=UserResponse, responses={
404: {"description": "User not found"},
409: {"description": "Email already taken"},
})
async def update_user(user_id: int, user: UserUpdate):
return await user_service.update(user_id, user)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(user_id: int):
await user_service.delete(user_id)
```
### Dependency Injection
#### Router aggregation
```python
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
# app/api/router.py
from fastapi import APIRouter
from app.api.v1 import users, items, auth
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/v1")
api_router.include_router(users.router, prefix="/v1")
api_router.include_router(items.router, prefix="/v1")
```
### 3. Dependency Injection
#### Basic dependency with Depends()
```python
from fastapi import Depends, Header, HTTPException
async def verify_api_key(x_api_key: str = Header(...)):
if x_api_key != settings.API_KEY:
raise HTTPException(status_code=403, detail="Invalid API key")
return x_api_key
```
#### Nested dependencies
```python
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
yield session # yield dependency — cleanup runs after response
@app.get("/users")
async def list_users(db: AsyncSession = Depends(get_db)):
return await db.execute(select(User))
async def get_user_repo(db: AsyncSession = Depends(get_db)) -> UserRepository:
return UserRepository(db)
async def get_user_service(
repo: UserRepository = Depends(get_user_repo),
) -> UserService:
return UserService(repo)
@router.get("/users")
async def list_users(service: UserService = Depends(get_user_service)):
return await service.list_all()
```
### Router Organization
#### Yield dependencies for cleanup
```python
from fastapi import APIRouter
async def get_redis() -> AsyncGenerator[Redis, None]:
redis = await aioredis.from_url(settings.REDIS_URL)
try:
yield redis
finally:
await redis.close() # Always runs, even on exceptions
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/")
async def list_users():
pass
# In main.py
app.include_router(router)
async def get_http_client() -> AsyncGenerator[httpx.AsyncClient, None]:
async with httpx.AsyncClient(timeout=30.0) as client:
yield client
```
#### Request-scoped dependencies with caching
```python
# FastAPI caches dependency results per-request by default.
# The same db session is reused if multiple deps request it.
@router.get("/dashboard")
async def dashboard(
user_service: UserService = Depends(get_user_service),
item_service: ItemService = Depends(get_item_service),
# Both services share the same db session from get_db()
):
users = await user_service.count()
items = await item_service.count()
return {"users": users, "items": items}
# To disable caching (get fresh instance each time):
@router.get("/example")
async def example(
db1: AsyncSession = Depends(get_db),
db2: AsyncSession = Depends(get_db, use_cache=False),
# db1 and db2 are different sessions
):
pass
```
#### Class-based dependencies
```python
class Pagination:
def __init__(
self,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
):
self.skip = skip
self.limit = limit
@router.get("/items")
async def list_items(pagination: Pagination = Depends()):
# pagination.skip, pagination.limit
pass
```
### 4. Middleware
#### Custom middleware
```python
import time
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
response.headers["X-Process-Time"] = f"{duration:.4f}"
return response
```
#### Pure ASGI middleware (higher performance)
```python
from starlette.types import ASGIApp, Receive, Scope, Send
class RequestIDMiddleware:
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] == "http":
request_id = uuid.uuid4().hex
scope.setdefault("state", {})["request_id"] = request_id
async def send_with_header(message):
if message["type"] == "http.response.start":
headers = dict(message.get("headers", []))
headers[b"x-request-id"] = request_id.encode()
message["headers"] = list(headers.items())
await send(message)
await self.app(scope, receive, send_with_header)
else:
await self.app(scope, receive, send)
```
#### Standard middleware configuration
```python
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.middleware.gzip import GZipMiddleware
def add_middleware(app: FastAPI):
# Order matters: first added = outermost (runs first on request, last on response)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS, # ["https://example.com"]
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
allow_headers=["*"],
)
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=settings.ALLOWED_HOSTS, # ["example.com", "*.example.com"]
)
app.add_middleware(GZipMiddleware, minimum_size=500) # Compress responses > 500 bytes
app.add_middleware(TimingMiddleware)
```
### 5. Background Tasks
#### Simple background tasks
```python
from fastapi import BackgroundTasks
async def send_welcome_email(email: str, name: str):
# This runs after the response is sent
await email_service.send(
to=email,
subject="Welcome!",
body=f"Hello {name}, welcome to our platform.",
)
async def log_activity(user_id: int, action: str):
await activity_repo.create(user_id=user_id, action=action)
@router.post("/users", status_code=201)
async def create_user(
user: UserCreate,
background_tasks: BackgroundTasks,
):
new_user = await user_service.create(user)
# Queue multiple background tasks
background_tasks.add_task(send_welcome_email, new_user.email, new_user.name)
background_tasks.add_task(log_activity, new_user.id, "account_created")
return new_user
```
#### Long-running tasks with task queues
For tasks that take more than a few seconds, use a proper task queue:
```python
from celery import Celery
celery_app = Celery("worker", broker=settings.CELERY_BROKER_URL)
@celery_app.task
def generate_report(report_id: int):
# Long-running: query data, build PDF, upload to S3
...
@router.post("/reports", status_code=202)
async def request_report(params: ReportRequest):
report = await report_service.create(params)
generate_report.delay(report.id) # Dispatch to Celery worker
return {"report_id": report.id, "status": "processing"}
@router.get("/reports/{report_id}/status")
async def report_status(report_id: int):
report = await report_service.get(report_id)
return {"status": report.status, "url": report.download_url}
```
### 6. WebSocket
#### WebSocket endpoint with connection management
```python
from fastapi import WebSocket, WebSocketDisconnect
class ConnectionManager:
def __init__(self):
self.active_connections: dict[str, list[WebSocket]] = {}
async def connect(self, websocket: WebSocket, room: str):
await websocket.accept()
self.active_connections.setdefault(room, []).append(websocket)
def disconnect(self, websocket: WebSocket, room: str):
self.active_connections.get(room, []).remove(websocket)
async def broadcast(self, message: str, room: str):
for connection in self.active_connections.get(room, []):
try:
await connection.send_text(message)
except WebSocketDisconnect:
self.disconnect(connection, room)
manager = ConnectionManager()
@app.websocket("/ws/{room}")
async def websocket_endpoint(websocket: WebSocket, room: str):
await manager.connect(websocket, room)
try:
while True:
data = await websocket.receive_text()
await manager.broadcast(f"Message: {data}", room)
except WebSocketDisconnect:
manager.disconnect(websocket, room)
await manager.broadcast(f"User left the room", room)
```
#### WebSocket with authentication
```python
@app.websocket("/ws/private")
async def private_ws(websocket: WebSocket, token: str = Query(...)):
try:
user = verify_token(token)
except InvalidToken:
await websocket.close(code=4001, reason="Invalid token")
return
await websocket.accept()
try:
while True:
data = await websocket.receive_json()
response = await process_message(user, data)
await websocket.send_json(response)
except WebSocketDisconnect:
pass
```
### 7. File Handling
#### Upload files
```python
from fastapi import UploadFile, File
@router.post("/upload")
async def upload_file(
file: UploadFile = File(..., description="File to upload"),
):
# Validate file type and size
if file.content_type not in ["image/png", "image/jpeg"]:
raise HTTPException(400, "Only PNG and JPEG images are allowed")
if file.size and file.size > 5 * 1024 * 1024: # 5 MB
raise HTTPException(400, "File too large (max 5 MB)")
contents = await file.read()
path = f"uploads/{uuid.uuid4()}_{file.filename}"
async with aiofiles.open(path, "wb") as f:
await f.write(contents)
return {"filename": file.filename, "path": path, "size": len(contents)}
# Multiple file upload
@router.post("/upload-multiple")
async def upload_multiple(files: list[UploadFile] = File(...)):
results = []
for file in files:
contents = await file.read()
results.append({"filename": file.filename, "size": len(contents)})
return results
```
#### Streaming responses
```python
from fastapi.responses import StreamingResponse
import csv
import io
@router.get("/export/users")
async def export_users():
async def generate_csv():
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "name", "email"])
yield output.getvalue()
output.seek(0)
output.truncate(0)
async for user in user_service.stream_all():
writer.writerow([user.id, user.name, user.email])
yield output.getvalue()
output.seek(0)
output.truncate(0)
return StreamingResponse(
generate_csv(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=users.csv"},
)
```
#### Static files
```python
from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static"), name="static")
```
### 8. Testing
#### TestClient for synchronous tests
```python
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_create_user():
response = client.post("/api/v1/users", json={
"email": "test@example.com",
"name": "Test User",
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "test@example.com"
def test_get_user_not_found():
response = client.get("/api/v1/users/99999")
assert response.status_code == 404
```
#### Async testing with httpx
```python
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def async_client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.mark.anyio
async def test_list_users(async_client: AsyncClient):
response = await async_client.get("/api/v1/users")
assert response.status_code == 200
assert isinstance(response.json(), list)
```
#### Overriding dependencies for tests
```python
from app.dependencies import get_db, get_current_user
from app.models.user import User
# Mock database session
async def override_get_db():
async with test_session_maker() as session:
yield session
# Mock authenticated user
async def override_get_current_user():
return User(id=1, email="test@example.com", name="Test")
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
# In conftest.py — clean up after tests
@pytest.fixture(autouse=True)
def clear_overrides():
yield
app.dependency_overrides.clear()
```
#### Testing WebSocket endpoints
```python
def test_websocket():
with client.websocket_connect("/ws/test-room") as ws:
ws.send_text("hello")
data = ws.receive_text()
assert "hello" in data
```
---
## Best Practices
1. Use Pydantic models for request/response validation
2. Organize routes with APIRouter
3. Use dependency injection for services
4. Return proper HTTP status codes
5. Add OpenAPI descriptions
1. **Use Pydantic models for all request/response validation** — never pass raw dicts through your API boundary. Define separate `Create`, `Update`, and `Response` schemas for each resource.
2. **Organize routes with APIRouter** — group related endpoints by resource and version. Apply shared dependencies at the router level, not on each individual route.
3. **Separate business logic from routes** — route functions should only handle HTTP concerns (parsing request, returning response). Delegate logic to service classes injected via `Depends()`.
4. **Use the lifespan context manager** — replace deprecated `on_event("startup")` and `on_event("shutdown")` with the `lifespan` async context manager for resource setup and teardown.
5. **Return proper HTTP status codes** — 201 for creation, 204 for deletion, 202 for accepted-but-not-done, 409 for conflicts. Use `status_code` parameter on route decorators.
6. **Add OpenAPI metadata** — provide `summary`, `description`, `tags`, and `responses` on routes. Set `title`, `version`, and `description` on the FastAPI app. This generates high-quality auto-docs.
7. **Use async all the way down** — if your route is `async def`, every I/O call inside it must also be async. Mixing sync blocking calls (e.g., `requests.get()`) in an async route will block the event loop.
8. **Configure settings with pydantic-settings** — load config from environment variables with validation and type coercion. Never hardcode secrets or connection strings.
```python
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str
API_KEY: str
DEBUG: bool = False
model_config = {"env_file": ".env"}
settings = Settings()
```
---
## Common Pitfalls
- **Blocking I/O in async**: Use async libraries
- **Missing response models**: Always define them
- **No error handling**: Use HTTPException properly
1. **Blocking I/O in async routes** — calling `requests.get()`, `time.sleep()`, or synchronous DB drivers inside `async def` routes starves the event loop. Use `httpx`, `asyncio.sleep()`, and async database drivers instead. If you must call sync code, use `run_in_executor`.
2. **Missing response_model** — without `response_model`, FastAPI returns whatever you return, potentially leaking internal fields (passwords, internal IDs). Always define a Pydantic response schema.
3. **Forgetting to await coroutines** — calling `await db.execute(query)` vs `db.execute(query)` is easy to miss. The latter returns a coroutine object instead of results. Enable linting rules that catch unawaited coroutines.
4. **Circular imports between models and schemas** — when schemas reference ORM models and vice versa, you get import cycles. Fix by using `TYPE_CHECKING` imports or by keeping schemas and models in separate modules that do not import each other.
5. **Not handling Pydantic validation errors** — FastAPI returns 422 by default, but the error format may confuse API consumers. Add a custom exception handler to reshape validation error responses to match your API's error format.
6. **Sharing mutable state across requests without locks** — global mutable variables (lists, dicts) accessed from async routes can cause race conditions. Use async-safe structures or dependency-injected per-request state.
---
## Related Skills
- `languages/python` — Python language patterns and best practices
- `api/openapi` — OpenAPI specification and documentation standards
- `databases/postgresql` — Database integration with async SQLAlchemy
- `testing/pytest` — Testing FastAPI applications with pytest and httpx
- `patterns/authentication` — JWT, OAuth2, and session patterns for FastAPI endpoints
- `patterns/logging` — Structured logging for FastAPI applications
@@ -0,0 +1,229 @@
# FastAPI Project Structure Reference
## Small Project (1-5 endpoints, single module)
```
project/
├── main.py # App factory, routes, startup
├── models.py # Pydantic schemas + SQLAlchemy models
├── database.py # DB connection, session factory
├── config.py # Settings via pydantic-settings
├── requirements.txt
├── .env
└── tests/
├── conftest.py # Fixtures (test client, test DB)
└── test_main.py
```
**When to use**: Prototypes, microservices, internal tools, single-domain APIs.
**`main.py` structure**:
```python
from fastapi import FastAPI
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
yield
# shutdown
app = FastAPI(lifespan=lifespan)
@app.get("/health")
async def health(): return {"status": "ok"}
```
---
## Medium Project (5-20 endpoints, feature-grouped)
```
project/
├── app/
│ ├── __init__.py
│ ├── main.py # App factory, include routers
│ ├── config.py # Settings (pydantic-settings)
│ ├── database.py # Engine, SessionLocal, Base
│ ├── dependencies.py # Shared deps (get_db, get_current_user)
│ ├── exceptions.py # Custom exception handlers
│ ├── middleware.py # CORS, logging, timing middleware
│ │
│ ├── auth/
│ │ ├── __init__.py
│ │ ├── router.py # POST /login, POST /register
│ │ ├── schemas.py # LoginRequest, TokenResponse
│ │ ├── models.py # User SQLAlchemy model
│ │ ├── service.py # Business logic (hash, verify, tokens)
│ │ └── dependencies.py # get_current_user, require_role
│ │
│ ├── items/
│ │ ├── __init__.py
│ │ ├── router.py # CRUD endpoints
│ │ ├── schemas.py # ItemCreate, ItemRead, ItemUpdate
│ │ ├── models.py # Item SQLAlchemy model
│ │ └── service.py # Business logic
│ │
│ └── shared/
│ ├── __init__.py
│ ├── pagination.py # Pagination params + response schema
│ └── filters.py # Common query filter patterns
├── alembic/ # DB migrations
│ ├── env.py
│ └── versions/
├── alembic.ini
├── requirements.txt
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
└── tests/
├── conftest.py
├── auth/
│ └── test_router.py
└── items/
├── test_router.py
└── test_service.py
```
**When to use**: Multi-feature APIs, team projects, typical SaaS backends.
**Key patterns**:
- Each feature gets its own directory with router, schemas, models, service
- `router.py` uses `APIRouter(prefix="/items", tags=["items"])`
- `main.py` includes routers: `app.include_router(items.router)`
- Shared deps in root `dependencies.py`, feature-specific in feature dir
---
## Large Project (20+ endpoints, domain-driven)
```
project/
├── src/
│ ├── __init__.py
│ ├── main.py # App factory only
│ ├── config.py # Layered settings
│ │
│ ├── core/ # Framework-level concerns
│ │ ├── __init__.py
│ │ ├── database.py # Engine, session management
│ │ ├── security.py # JWT, hashing, RBAC
│ │ ├── exceptions.py # Base exceptions + handlers
│ │ ├── middleware.py # All middleware stack
│ │ ├── dependencies.py # Cross-cutting deps
│ │ ├── events.py # Domain event bus
│ │ └── pagination.py # Cursor + offset pagination
│ │
│ ├── domain/ # Business logic (framework-agnostic)
│ │ ├── users/
│ │ │ ├── __init__.py
│ │ │ ├── entity.py # Domain entity (plain dataclass)
│ │ │ ├── repository.py # Abstract repository interface
│ │ │ ├── service.py # Business rules
│ │ │ └── events.py # Domain events
│ │ ├── orders/
│ │ │ └── ...
│ │ └── payments/
│ │ └── ...
│ │
│ ├── infrastructure/ # External system adapters
│ │ ├── database/
│ │ │ ├── models.py # All SQLAlchemy models
│ │ │ ├── repositories/ # Concrete repo implementations
│ │ │ │ ├── user_repo.py
│ │ │ │ └── order_repo.py
│ │ │ └── migrations/ # Alembic
│ │ ├── cache/
│ │ │ └── redis_client.py
│ │ ├── email/
│ │ │ └── smtp_service.py
│ │ └── external/
│ │ └── stripe_client.py
│ │
│ └── api/ # HTTP layer only
│ ├── __init__.py
│ ├── v1/
│ │ ├── __init__.py # v1 router aggregator
│ │ ├── users.py # Thin: parse request -> call service -> format response
│ │ ├── orders.py
│ │ └── payments.py
│ ├── v2/
│ │ └── ...
│ ├── schemas/ # Request/response schemas
│ │ ├── user_schemas.py
│ │ ├── order_schemas.py
│ │ └── common.py
│ ├── dependencies.py # API-layer deps
│ └── websockets/
│ └── notifications.py
├── tests/
│ ├── conftest.py
│ ├── unit/
│ │ ├── domain/
│ │ │ └── test_user_service.py
│ │ └── ...
│ ├── integration/
│ │ ├── test_user_api.py
│ │ └── test_order_flow.py
│ └── e2e/
│ └── test_checkout.py
├── scripts/ # Dev/ops scripts
│ ├── seed_db.py
│ └── migrate.py
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
└── Makefile
```
**When to use**: Complex domains, multiple teams, long-lived products.
**Key patterns**:
- **Domain layer** has zero framework imports (testable in isolation)
- **Infrastructure** implements domain interfaces (repository pattern)
- **API layer** is thin: validation, auth, call service, return schema
- API versioning via `/api/v1/`, `/api/v2/`
- Separate unit, integration, and e2e test directories
---
## File Responsibilities
| File | Responsibility | Dependencies |
|------|---------------|-------------|
| `router.py` | HTTP handling, request parsing, response formatting | schemas, service, dependencies |
| `schemas.py` | Pydantic models for request/response validation | None (or shared schemas) |
| `models.py` | SQLAlchemy/ODM models (DB table mapping) | database |
| `service.py` | Business logic, orchestration | repository/models, external services |
| `dependencies.py` | FastAPI `Depends()` callables | database, config, auth |
| `exceptions.py` | Custom exceptions + handlers | None |
| `config.py` | `BaseSettings` with env loading | None |
## Router Registration Pattern
```python
# app/main.py
from fastapi import FastAPI
from app.auth.router import router as auth_router
from app.items.router import router as items_router
def create_app() -> FastAPI:
app = FastAPI(title="My API")
app.include_router(auth_router)
app.include_router(items_router)
return app
app = create_app()
```
```python
# app/items/router.py
from fastapi import APIRouter, Depends
router = APIRouter(prefix="/items", tags=["items"])
@router.get("/")
async def list_items(db=Depends(get_db)): ...
```