Python and integration systems are my deepest area, so the backend was the first section I built once requirements were set. I started simple, models and routers only. As the app grew and more business logic piled up, I split out a services layer to keep routers thin and business logic isolated and testable.
The result is three layers with strict boundaries:
- Routers — request/response only. Validate input, call a service, shape the response. No business logic.
- Services — own the business logic and orchestration. This is also where tenant/user context gets resolved (
get_active_context, covered in/auth-and-multi-tenancy). - Models — SQLModel classes. Data shape only, no behavior.
Project Layout
app/
main.py FastAPI app + router registration
deps.py DI: get_active_context, get_current_user, require_role
db.py async engine/session setup
models.py SQLModel classes
schemas.py request/response shapes
auth.py JWT issuing/verification
rate_limit.py slowapi config
celery_client.py Celery app instance
routers/ one file per domain: accounts, auth, budgets, categories, imports, tenants, transactions, users
services/ same domain split, one-to-one with routers/
storage/ swappable import-file backend: base.py interface, local.py, s3.py
One router file maps to exactly one service file per domain, accounts.py in both, same for the rest. deps.py is where get_active_context and the role-checking dependencies live, it’s the single file every router imports from for auth/tenant context, which is what keeps that logic out of the services layer.
The storage/ split (local.py vs s3.py behind a shared base.py interface) is what lets CSV import attachments work the same way whether the app’s running self-hosted or on AWS. Full detail on that in /self-hosted-vps and /aws-architecture.
Tech Stack
- FastAPI 0.137.2 — see Framework Choice above
- SQLModel 0.0.22 on SQLAlchemy 2.0.35 — the ORM layer
- asyncpg 0.29.0 — async Postgres driver, the whole request path is async, not just the framework
- Alembic 1.13.3 — migrations, autogenerated diffs
- pytest 9.0.3, pytest-asyncio, httpx, aiosqlite — test stack. Cross-tenant isolation gets verified via SQLite dependency overrides instead of spinning up Postgres for every test run
- slowapi 0.1.9 — in-process rate limiting on the public demo’s auth endpoints
- boto3 — lazy-imported, only loaded when running in AWS Aurora mode
Framework Choice
FastAPI was the choice from day one, no real bake-off against Django or Flask. Built-in Pydantic validation and auto-generated OpenAPI docs cut out boilerplate I’d otherwise write by hand, and it’s the framework I know best. I’d already weighed the alternatives enough on past projects to know FastAPI fit this one.
Migrations
Migrations run through Alembic, autogenerating diffs against the SQLModel classes. Nothing dramatic to report: every migration so far has been additive (new column, new table), nothing that needed a manual hand-edit.