# FastAPI Feature Module Architecture

> Pattern for adding new feature modules to a FastAPI project. Router / Schema / Service / Repository per module.

---

## Module Structure

```
app/
├── api/v1/<module>.py            # Router (thin HTTP layer)
├── schemas/<module>.py           # Request/response (pydantic)
├── services/<module>.py          # Business logic
├── repositories/<module>.py      # Data access (SQL/ORM)
├── models/<module>.py            # DB/domain model
└── dependencies.py               # DI factory (add entry)
```

All files for one module share the same `<module>` name (e.g. `gpx`, `route`, `elevation`).

---

## Data Flow

```
Request -> Router -> Service -> Repository -> DB
                       |
                       +------> ExternalClient -> Third-party API
```

---

## Layer Responsibilities

| Layer | File | Does | Does NOT |
|-------|------|------|----------|
| **Router** | `api/v1/<module>.py` | Validate input (schema), call service, return response | Business logic, DB access |
| **Schema** | `schemas/<module>.py` | Define request/response shape, field validation | Logic, DB access |
| **Service** | `services/<module>.py` | Business logic, orchestrate repo + external calls | Direct HTTP handling, SQL |
| **Repository** | `repositories/<module>.py` | Single data operation (find, create, update, delete) | Business logic, validation |
| **Model** | `models/<module>.py` | DB table definition, domain model | HTTP handling, business logic |
| **Dependencies** | `dependencies.py` | Wire repo -> service via FastAPI Depends | Logic, routing |

---

## Step 1: Create Model

```python
# app/models/{module}.py

from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, DateTime
from datetime import datetime
from app.core.database import Base

class {Module}(Base):
    __tablename__ = "{module_plural}"

    id: Mapped[str] = mapped_column(String, primary_key=True)
    name: Mapped[str] = mapped_column(String(200))
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
```

---

## Step 2: Create Schemas

```python
# app/schemas/{module}.py

from pydantic import BaseModel, Field
from datetime import datetime

# --- Request ---

class Create{Module}Request(BaseModel):
    name: str = Field(..., min_length=1, max_length=200)
    description: str | None = None

class Update{Module}Request(BaseModel):
    name: str | None = Field(None, min_length=1, max_length=200)
    description: str | None = None

# --- Response ---

class {Module}DetailResponse(BaseModel):
    id: str
    name: str
    description: str | None
    created_at: datetime

    model_config = {"from_attributes": True}

class {Module}ListResponse(BaseModel):
    items: list[{Module}DetailResponse]
    total: int
    page: int
    per_page: int
```

---

## Step 3: Create Repository

```python
# app/repositories/{module}.py

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.models.{module} import {Module}

class {Module}Repository:
    def __init__(self, session: AsyncSession):
        self.session = session

    async def find_by_id(self, id: str) -> {Module} | None:
        result = await self.session.execute(
            select({Module}).where({Module}.id == id)
        )
        return result.scalar_one_or_none()

    async def find_many(self, page: int = 1, per_page: int = 10) -> list[{Module}]:
        offset = (page - 1) * per_page
        result = await self.session.execute(
            select({Module})
            .offset(offset)
            .limit(per_page)
            .order_by({Module}.created_at.desc())
        )
        return list(result.scalars().all())

    async def count(self) -> int:
        result = await self.session.execute(
            select(func.count()).select_from({Module})
        )
        return result.scalar_one()

    async def create(self, data: dict) -> {Module}:
        item = {Module}(**data)
        self.session.add(item)
        await self.session.commit()
        await self.session.refresh(item)
        return item
```

---

## Step 4: Create Service

```python
# app/services/{module}.py

from app.repositories.{module} import {Module}Repository
from app.schemas.{module} import Create{Module}Request
from app.core.exceptions import NotFoundError

class {Module}Service:
    def __init__(self, repo: {Module}Repository):
        self.repo = repo

    async def get_list(self, page: int = 1, per_page: int = 10):
        items = await self.repo.find_many(page=page, per_page=per_page)
        total = await self.repo.count()
        return {"items": items, "total": total, "page": page, "per_page": per_page}

    async def get_detail(self, id: str):
        item = await self.repo.find_by_id(id)
        if not item:
            raise NotFoundError(f"{Module} {id} not found")
        return item

    async def create(self, body: Create{Module}Request):
        return await self.repo.create(body.model_dump())
```

---

## Step 5: Create Router

**Rule: Router is thin.** Only validate input, call service, return response.

```python
# app/api/v1/{module}.py

from fastapi import APIRouter, Depends
from app.schemas.{module} import Create{Module}Request, {Module}DetailResponse, {Module}ListResponse
from app.services.{module} import {Module}Service
from app.dependencies import get_{module}_service

router = APIRouter(prefix="/{module}", tags=["{module}"])

@router.get("/", response_model={Module}ListResponse)
async def get_list(
    page: int = 1,
    per_page: int = 10,
    service: {Module}Service = Depends(get_{module}_service),
):
    return await service.get_list(page=page, per_page=per_page)

@router.get("/{id}", response_model={Module}DetailResponse)
async def get_detail(
    id: str,
    service: {Module}Service = Depends(get_{module}_service),
):
    return await service.get_detail(id)

@router.post("/", response_model={Module}DetailResponse, status_code=201)
async def create(
    body: Create{Module}Request,
    service: {Module}Service = Depends(get_{module}_service),
):
    return await service.create(body)
```

---

## Step 6: Wire Dependencies

```python
# app/dependencies.py (add entry per module)

from app.repositories.{module} import {Module}Repository
from app.services.{module} import {Module}Service

def get_{module}_service(
    session: AsyncSession = Depends(get_session),
) -> {Module}Service:
    repo = {Module}Repository(session)
    return {Module}Service(repo)
```

---

## Step 7: Register Router

```python
# app/api/router.py

from app.api.v1 import {module}

api_router = APIRouter()
api_router.include_router({module}.router, prefix="/v1")
```

---

## Module Without DB (pure logic / external API)

Skip model and repository. Service calls external client directly.

```
app/
├── api/v1/{module}.py       # Router
├── schemas/{module}.py      # Request/response
├── services/{module}.py     # Logic + external API call
└── dependencies.py          # DI (no repo needed)
```

```python
# app/services/{module}.py

from app.core.http_client import external_client

class {Module}Service:
    async def get_data(self, params: dict) -> list:
        response = await external_client.post("/endpoint", params)
        return response["results"]
```

---

## Adding a New Module (checklist)

1. `app/models/{module}.py` — DB model (skip if no DB)
2. `app/schemas/{module}.py` — Request/response schemas
3. `app/repositories/{module}.py` — Data access (skip if no DB)
4. `app/services/{module}.py` — Business logic
5. `app/api/v1/{module}.py` — Router (thin)
6. `app/dependencies.py` — Add DI factory
7. `app/api/router.py` — Include router
8. `tests/test_{module}.py` — Tests

---

## Naming Conventions

| Item | Convention | Example |
|------|-----------|---------|
| Module file | `snake_case` | `gpx.py`, `route.py` |
| Class | `PascalCase` | `GpxService`, `GpxRepository` |
| Function | `snake_case` | `get_detail`, `generate` |
| Schema (request) | `{Action}{Module}Request` | `CreateGpxRequest` |
| Schema (response) | `{Module}{Context}Response` | `GpxDetailResponse` |
| Router prefix | `/{module}` | `/gpx`, `/route` |
| Router tag | `lowercase` | `"gpx"`, `"route"` |
| DB table | `snake_case plural` | `gpx_activities` |
| DB model class | `PascalCase` | `GpxActivity` |
| DI factory | `get_{module}_service` | `get_gpx_service` |

---

## Anti-Patterns

| Anti-Pattern | Correct Approach |
|-------------|-----------------|
| Business logic in router | Router is thin, move to service |
| SQL in service | Use repository |
| Schema validation in service | Define constraints in schema (Field, validators) |
| Hardcoded URLs | Use `config.py` settings |
| Sync HTTP calls | Use `httpx.AsyncClient` |
| Fat service with everything | Split: service = logic, repo = data, client = external |
