++++A complete map of the repository layout and a deep walkthrough of every recurring code pattern — frozen dataclasses, SQLAlchemy 2.0 mapped columns, Pydantic ConfigDict, annotated DI providers, and more.
0. Project Structure & Code Patterns
Reading the Folder Tree
Before diving into any layer, it helps to hold the whole tree in your head. Every directory in this project has a specific job, and knowing which file lives where prevents you from adding code to the wrong place.
.
├── app/
│ └── server.py # Uvicorn entry point
├── scripts/
│ └── init_db.py # Database bootstrap CLI
├── tests/
│ ├── conftest.py # Pytest path setup
│ ├── test_auth_middleware.py # Auth enforcement tests
│ ├── test_logging.py # Request logging tests
│ ├── test_todo_crud.py # Service-layer unit tests
│ └── test_todo_router_pydantic.py # Schema and router contract tests
├── server/
│ ├── api/
│ │ ├── config/src/
│ │ │ ├── main.py # Alternative app factory (for test injection)
│ │ │ └── state.py # Typed AppState dataclass
│ │ └── src/
│ │ ├── main.py # Primary app factory (create_app)
│ │ ├── dependencies.py # All DI providers in one place
│ │ ├── db/
│ │ │ ├── models.py # SQLAlchemy ORM entities (TodoEntity)
│ │ │ └── session.py # Engine and session factory helpers
│ │ ├── middleware/
│ │ │ └── auth_middleware.py # HTTP auth enforcement
│ │ ├── repositories/
│ │ │ └── todo_repository.py # Persistence + domain mapping
│ │ ├── routers/
│ │ │ ├── __init__.py # init_routes — mounts all routers
│ │ │ ├── auth_router.py # /api/auth endpoints
│ │ │ └── todo_router.py # /api/todos endpoints
│ │ ├── services/
│ │ │ ├── auth_service.py # JWT issuance and validation
│ │ │ └── todo_service.py # Todo business logic
│ │ └── utils/
│ │ ├── error.py # Shared error helpers
│ │ └── logging.py # Loguru configuration + middleware
│ ├── migration/
│ │ └── src/
│ │ ├── lib.py # Migrator registry (Migration dataclass)
│ │ ├── runner.py # upgrade() / downgrade() async functions
│ │ └── m20260408_000001_create_todos_table.py # First migration
│ └── shared/
│ └── src/
│ ├── models/
│ │ ├── auth.py # AuthUser domain dataclass
│ │ └── todo.py # Todo domain dataclass + TodoStatus enum
│ ├── schemas/
│ │ ├── auth.py # LoginRequest, TokenResponse, AuthUserResponse
│ │ └── todo.py # CreateTodoRequest, TodoResponse
│ └── utils/
│ └── error.py # Shared utilities
└── pyproject.toml # Poetry deps, Ruff config, Pytest configThe Three Module Roots
The project is divided into three top-level namespaces under server/, each with a distinct responsibility:
server/api/src/ — runtime API code. This is where FastAPI, SQLAlchemy, middleware, routers, services, and repositories live. Everything here is HTTP- and database-aware.
server/shared/src/ — shared domain code. Plain Python dataclasses and Pydantic schemas. No FastAPI imports. No SQLAlchemy. This is the language the whole application speaks internally — domain models flow from persistence through services through to API responses.
server/migration/src/ — schema evolution. Async migration modules and the runner that applies them. Completely separate from the application runtime.
The rule is: code in server/api/src/ can import from server/shared/src/, but server/shared/src/ must never import from server/api/src/. The shared layer has no knowledge of HTTP or database mechanics.
Code Patterns
1. Frozen Dataclass — Domain Models
Domain models in server/shared/src/models/ are plain Python @dataclass(frozen=True) objects:
# server/shared/src/models/todo.py
class TodoStatus(str, Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
@dataclass(frozen=True)
class Todo:
id: UUID
title: str
description: str | None
status: TodoStatus
created_at: datetime
updated_at: datetimefrozen=True makes instances immutable — you can't accidentally mutate a Todo after it's created. Business logic that needs a modified todo (e.g., completing it) constructs a new Todo instance with the changed fields rather than mutating the existing one. This eliminates a whole class of subtle bugs where a service mutates an object that's also referenced somewhere else.
TodoStatus inherits from both str and Enum. The str base means the enum values serialize directly to strings ("pending", "completed") in JSON without any custom serializer — FastAPI and Pydantic handle it transparently.
The AuthUser model follows the same pattern:
# server/shared/src/models/auth.py
@dataclass(frozen=True)
class AuthUser:
id: UUID
name: str
email: str
created_at: datetime
updated_at: datetime2. SQLAlchemy 2.0 Mapped Column — ORM Entities
The TodoEntity in server/api/src/db/models.py uses SQLAlchemy 2.0's typed mapped column syntax:
# server/api/src/db/models.py
class TodoEntity(Base):
__tablename__ = "todos"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid4())
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[TodoStatus] = mapped_column(
Enum(TodoStatus), default=TodoStatus.PENDING, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)Mapped[str] is the 2.0 style — it gives the ORM full type information at the class level, enabling better IDE support and static analysis. mapped_column() replaces the old Column() API.
The UUID is stored as String(36) because SQLite doesn't have a native UUID column type. The conversion to and from UUID happens in the repository's _map_to_domain method — the ORM entity is just a persistence detail.
expire_on_commit=False is set on the session factory. Without this, SQLAlchemy expires all attributes on every commit, meaning a second access to any field after a commit would trigger another database round-trip. With async sessions, that's a coroutine you'd have to await — inside a sync property access that can't be awaited. Setting expire_on_commit=False prevents that, at the cost of the session holding slightly stale data (acceptable here since repositories are short-lived).
3. App Factory Pattern — create_app()
The application is created by a factory function in server/api/src/main.py, not at module level:
# server/api/src/main.py
def create_app() -> FastAPI:
configure_logging()
app = FastAPI(title=SERVICE_NAME, lifespan=lifespan, ...)
# Database setup
engine = create_async_engine("sqlite+aiosqlite:///./test.db")
async_session = async_sessionmaker(engine, expire_on_commit=False)
# Service initialization
session = async_session()
repo = TodoRepository(session)
todo_service = TodoService(repo)
auth_service = AuthService()
# State injection
app.state.todo_service = todo_service
app.state.auth_service = auth_service
attach_request_logging(app)
attach_auth_middleware(app)
init_routes(app)
return appThe entry point app/server.py is intentionally thin — just one call:
# app/server.py
from server.api.src.main import create_app
app = create_app()The factory pattern means tests can call create_app() too (or the alternative create_application() in server/api/config/src/main.py when they need to inject a test database engine). You never have a module-level app = FastAPI() that's hard to control in tests.
The wiring order inside create_app() matters: logging middleware is attached first (so it wraps everything), then auth middleware (so it runs inside logging), then routes are registered last.
4. Lifespan Context — Startup and Shutdown
The lifespan context manager replaces the deprecated @app.on_event("startup") decorator:
# server/api/src/main.py
@asynccontextmanager
async def lifespan(_app: FastAPI):
log_app_startup(service=SERVICE_NAME, version=SERVICE_VERSION)
yield
log_app_shutdown(service=SERVICE_NAME, version=SERVICE_VERSION)Everything before yield runs on startup; everything after runs on shutdown. The lifespan is passed to the FastAPI constructor. This pattern composes cleanly — if you need to run migrations on startup or close an HTTP client on shutdown, you add it here, not scattered across event handlers.
5. Annotated Dependency Injection — TodoServiceDep
FastAPI's dependency injection uses Depends(), but writing Annotated[TodoService, Depends(get_todo_service)] in every route signature is verbose. The convention in this template is to define named type aliases in server/api/src/dependencies.py:
# server/api/src/dependencies.py
def get_todo_service(request: Request) -> TodoService:
return request.app.state.todo_service
TodoServiceDep = Annotated[TodoService, Depends(get_todo_service)]
def get_current_user(request: Request) -> AuthUser:
user = getattr(request.state, "auth_user", None)
if user is None:
raise HTTPException(status_code=401, detail="Unauthorized")
return user
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
CurrentUserDep = Annotated[AuthUser, Depends(get_current_user)]Route signatures become clean and readable:
# Usage in router
async def create_todo(body: CreateTodoRequest, service: TodoServiceDep): ...
async def get_authenticated_user(user: CurrentUserDep) -> AuthUserResponse: ...The get_current_user provider also acts as a safety net — if middleware failed to set auth_user for some reason, the dependency raises a 401 before the handler body runs. Auth enforcement is checked twice.
The single file location means that in tests, you can override the entire dependency by pointing the override map at a mock provider — one change swaps the dependency for every route that uses it.
6. Middleware Attachment Pattern
Middleware is not registered directly on the FastAPI instance — it's attached through named functions:
# Attachment functions
attach_request_logging(app) # in server/api/src/utils/logging.py
attach_auth_middleware(app) # in server/api/src/middleware/auth_middleware.pyEach function uses @app.middleware("http") internally:
# server/api/src/middleware/auth_middleware.py
def attach_auth_middleware(app: FastAPI) -> None:
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
...This pattern keeps middleware definition close to its implementation file. The alternative — registering middleware inside create_app() directly — works but mixes concerns. Here, create_app() reads as a wiring list: logging, auth, routes. The implementation details stay in their own modules.
7. Router Init Pattern — init_routes()
All route registration happens in a single init_routes() function in server/api/src/routers/__init__.py:
# server/api/src/routers/__init__.py
def init_routes(app: FastAPI) -> None:
api_router = APIRouter(prefix="/api")
api_router.include_router(auth_router.router, prefix="/auth")
api_router.include_router(todo_router.router, prefix="/todos")
app.include_router(api_router)The prefix hierarchy is: app → /api → /auth or /todos. This means:
POST /api/auth/tokenGET /api/auth/meGET /api/todos/POST /api/todos/PATCH /api/todos/{id}/complete
Adding a new resource means creating a new router file and including it here. The init_routes() function is also directly imported by test setup (from server.api.src.routers import init_routes) so tests mount exactly the same routes as production.
8. Pydantic ConfigDict Pattern — Request and Response Schemas
All Pydantic models in server/shared/src/schemas/ use ConfigDict for consistent configuration:
# ConfigDict on every model
class CreateTodoRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
...
class TodoResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
...extra="forbid" applies to both request and response models. On requests, it blocks unknown incoming fields. On responses, it means from_domain() can only pass fields the schema declares — if from_domain() accidentally passes an unexpected keyword argument, Pydantic raises a ValidationError immediately at construction time rather than silently including or ignoring it.
StringConstraints creates reusable type aliases:
TitleStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=200)]
DescriptionStr = Annotated[str, StringConstraints(max_length=2000)]Declaring these as Annotated type aliases means the constraints appear once and can be reused across multiple schemas. They also propagate into the OpenAPI schema automatically — the generated /openapi.json will include minLength: 1, maxLength: 200 on the title field.
9. Migration Module Pattern — up and down
Each migration is a standalone Python module with two async functions:
# server/migration/src/m20260408_000001_create_todos_table.py
async def up(conn: AsyncConnection) -> None:
await conn.execute(text("CREATE TABLE IF NOT EXISTS todos (...)"))
async def down(conn: AsyncConnection) -> None:
await conn.execute(text("DROP TABLE IF EXISTS todos;"))The naming convention m{YYYYMMDD}_{sequence}_{description}.py gives you:
- Chronological ordering when sorted alphabetically
- A unique sequence number for same-day migrations
- A human-readable description in the filename
The Migrator registry in lib.py holds references to the up and down functions, not the module itself. This means you can import and unit-test individual migration functions directly if needed.
CREATE TABLE IF NOT EXISTS makes up idempotent — running it twice doesn't fail. This matters during testing, where the database may be reset and re-initialized between test runs.
How the Layers Connect in Practice
To close the loop, here's the full request path for POST /api/todos/ annotated against the folder map:
HTTP Request
↓
server/api/src/utils/logging.py # request logging middleware assigns request_id
↓
server/api/src/middleware/auth_middleware.py # auth middleware checks Authorization header
↓
server/api/src/routers/todo_router.py # router validates CreateTodoRequest body
↓
server/api/src/dependencies.py # TodoServiceDep resolves from app.state
↓
server/api/src/services/todo_service.py # service creates Todo domain object
↓
server/api/src/repositories/todo_repository.py # repo maps Todo → TodoEntity, commits
↓
server/api/src/db/models.py # SQLAlchemy writes to todos table
↑
server/shared/src/models/todo.py # Todo dataclass returned up through repo and service
↑
server/shared/src/schemas/todo.py # TodoResponse.from_domain() maps to API shape
↑
HTTP ResponseEach arrow is a clean handoff. No layer reaches past its direct neighbor. The shared domain objects (Todo, AuthUser) are the common currency that all layers speak, and the schemas are the translation layer at the API boundary.