128 lines
4.1 KiB
Markdown
128 lines
4.1 KiB
Markdown
# Persistence
|
|
|
|
Keep session lifetime and write boundaries explicit. Route handlers can orchestrate straightforward database work, but persistence concerns should stay visible instead of hiding behind ambient sessions or magical commits.
|
|
|
|
## Why this convention exists
|
|
|
|
FastAPI apps often stay clean until persistence gets sloppy. Then you get:
|
|
|
|
- hidden session creation inside helpers
|
|
- unclear transaction boundaries
|
|
- handlers packed with ORM choreography
|
|
- tests that only pass because the database layer was mocked out of existence
|
|
|
|
The stronger pattern in mature examples is straightforward:
|
|
|
|
- sessions arrive through dependencies
|
|
- read and write boundaries are visible in the calling flow
|
|
- sync and async persistence are deliberate choices
|
|
- tests exercise real session lifetime and cleanup
|
|
|
|
## The convention
|
|
|
|
1. Provide sessions through dependencies.
|
|
2. Keep transaction and commit boundaries visible.
|
|
3. Make sync versus async persistence an explicit stack decision.
|
|
4. Let routes do simple persistence orchestration, but move reusable business rules out once the flow stops being trivial.
|
|
5. Test against real persistence seams when practical.
|
|
|
|
## Follow it when stored state matters
|
|
|
|
Typical fits:
|
|
|
|
- routes that read or write the database
|
|
- flows where transaction scope matters
|
|
- auth-aware queries
|
|
- cleanup-sensitive tests
|
|
|
|
## Avoid these mistakes
|
|
|
|
Do *not* invent a repository or service layer for every one-line query.
|
|
|
|
Do *not* hide session creation deep inside helper functions.
|
|
|
|
Do *not* force async persistence just because the web layer is async. Choose sync or async deliberately based on the real stack.
|
|
|
|
## Preferred shapes
|
|
|
|
### Session through dependency
|
|
|
|
```python
|
|
def get_db() -> Generator[Session, None, None]:
|
|
with Session(engine) as session:
|
|
yield session
|
|
```
|
|
|
|
This makes resource lifetime explicit and testable.
|
|
|
|
### Visible write boundary
|
|
|
|
```python
|
|
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
|
|
session.add(item)
|
|
session.commit()
|
|
session.refresh(item)
|
|
return item
|
|
```
|
|
|
|
This is honest about where state changes happen.
|
|
|
|
### Explicit partial-update flow
|
|
|
|
```python
|
|
update_dict = item_in.model_dump(exclude_unset=True)
|
|
item.sqlmodel_update(update_dict)
|
|
session.add(item)
|
|
session.commit()
|
|
session.refresh(item)
|
|
```
|
|
|
|
Again: no hidden writes, no mystery transaction.
|
|
|
|
## Counterexamples
|
|
|
|
### Hidden ambient session access
|
|
|
|
```python
|
|
def create_item(item_in: ItemCreate) -> Item:
|
|
session = Session(engine)
|
|
...
|
|
```
|
|
|
|
Bad because session lifetime is buried in the wrong layer.
|
|
|
|
### Commit hidden deep in helpers
|
|
|
|
If callers cannot tell where the write boundary is, transaction reasoning gets ugly fast.
|
|
|
|
### Mock-only persistence tests
|
|
|
|
If no test ever exercises real session lifetime, query shape, or cleanup behavior, the persistence layer will surprise you in production.
|
|
|
|
## Source signals
|
|
|
|
### Full-stack FastAPI Template
|
|
|
|
- `backend/app/api/deps.py:21-27` provides DB access through a yielded session dependency.
|
|
- `backend/app/api/routes/items.py:21-45` keeps read query shape explicit in the route, including auth-aware filtering and response shaping.
|
|
- `backend/app/api/routes/items.py:68-72` shows a visible create, commit, and refresh boundary.
|
|
- `backend/app/api/routes/items.py:86-95` shows the same visibility on update.
|
|
- `backend/app/api/routes/items.py:106-112` shows delete followed by an explicit commit.
|
|
- `backend/tests/conftest.py:15-24` keeps test session lifetime and cleanup explicit.
|
|
|
|
### SQLAlchemy and Starlette lifespan
|
|
|
|
- `examples/asyncio/async_orm.py:63-104` in SQLAlchemy shows explicit async session and transaction handling instead of hidden ambient state.
|
|
- `examples/inheritance/joined.py:93-120` shows the sync equivalent with visible `Session(...)` and `commit()` boundaries.
|
|
- `starlette/applications.py:46-48` reinforces that long-lived app resources belong in lifespan rather than being initialized lazily inside request-time persistence helpers.
|
|
|
|
## Bottom line
|
|
|
|
Persistence should be visible.
|
|
|
|
Sessions should come from clear boundaries.
|
|
|
|
Transactions should not be magical.
|
|
|
|
If a reader cannot tell where data is loaded, changed, and committed, the persistence design needs work.
|