simplified a bit

This commit is contained in:
John Lancaster
2026-07-26 20:11:25 -05:00
parent aed2e41ef0
commit bc0d6ede49
3 changed files with 61 additions and 50 deletions
@@ -29,7 +29,7 @@ Define one canonical session model for FastAPI + SQLAlchemy asyncio:
## Rules
- Create one cached `async_sessionmaker` per app-owned AsyncEngine.
- Let repositories resolve the cached maker by database URL, with an injectable factory override for tests.
- Let repositories resolve the cached maker by database URL.
- Use a fresh AsyncSession for each request or explicit unit-of-work.
- Pass an `AsyncSession` directly to data-access functions.
- Borrow a caller-provided session without closing or committing it.
@@ -40,6 +40,38 @@ Define one canonical session model for FastAPI + SQLAlchemy asyncio:
---
## Sessions and Transactions
A session and a transaction solve related but different problems:
| Concept | Responsibility | Typical lifetime |
| --- | --- | --- |
| `AsyncSession` | Provides the ORM workspace: executes queries, tracks loaded and changed objects in its identity map, and flushes pending changes. It also coordinates access to a database connection. | One request, task, or explicit unit of work. |
| Transaction | Defines the atomic database boundary: all work inside it commits together on success or rolls back together on failure. | One complete operation that must have a single outcome. |
A transaction belongs to a session; it is not an alternative to one. The session is the interface used by application and data-access code, while the transaction determines when that work becomes permanent. A session may coordinate sequential transactions during its lifetime, although short-lived application scopes commonly use one session for one transaction.
Use a session without a helper-owned commit boundary for independent reads or lower-level functions that must participate in whatever transaction their caller controls:
```python
async with session_factory() as session:
item = await find_item(session, item_id)
```
Use an explicit transaction for writes, read-modify-write operations, or several statements that must succeed or fail as one unit:
```python
async with session_factory.begin() as session:
order = await create_order(session, order_data)
await reserve_inventory(session, order)
```
SQLAlchemy sessions use [autobegin](https://docs.sqlalchemy.org/en/21/orm/session_basics.html#auto-begin), so the first database operation normally starts a transaction even for a read. Therefore, “session-only” means that the surrounding helper owns only session lifetime and does not promise to commit; it does not mean that no database transaction exists. Closing such a session releases its resources and rolls back any unfinished transaction. An explicit `begin()` is valuable when application code must make the atomic boundary and commit ownership visible.
For most read-only operations, a session context is sufficient. Use an explicit transaction for reads when they need a defined consistency boundary, participate in a larger atomic operation, or use locking such as `SELECT ... FOR UPDATE`.
---
## Session Factory Mechanics
An `async_sessionmaker[AsyncSession]` is a reusable configuration object and callable session producer. It stores how sessions should be created, including the engine binding and options such as `expire_on_commit=False`. It is not itself a session, connection, or transaction, and calling it does not make a shared global `AsyncSession`.
@@ -106,15 +138,12 @@ async def session_scope(
*,
database_url: str,
session: AsyncSession | None = None,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> AsyncIterator[AsyncSession]:
if session is not None:
yield session
return
active_factory = session_factory or get_session_factory(database_url)
async with active_factory() as owned_session:
async with get_session_factory(database_url)() as owned_session:
yield owned_session
```
@@ -125,8 +154,7 @@ This helper manages session lifetime only:
- It does not close, commit, or roll back a supplied session; the caller owns it.
- It closes a session that it creates. Closing releases resources and rolls back an unfinished transaction; it does not commit.
- It does not start a transaction. Put `session.begin()` at the use-case boundary.
- A supplied session wins; neither the factory override nor cached factory is used.
- A supplied factory overrides cached resolution, which keeps tests and specialized wiring explicit.
- A supplied session wins; the cached factory is not resolved.
- Otherwise, `database_url` selects the cached factory returned by `get_session_factory()`.
Do not turn this into an implicit unit-of-work helper that sometimes commits. Whether work joins an existing transaction or creates a new one must remain visible to the caller.
@@ -143,7 +171,6 @@ async def transaction_scope(
*,
database_url: str,
session: AsyncSession | None = None,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> AsyncIterator[AsyncSession]:
if session is not None:
if not session.in_transaction():
@@ -151,9 +178,22 @@ async def transaction_scope(
yield session
return
active_factory = session_factory or get_session_factory(database_url)
session_factory = get_session_factory(database_url)
async with session_factory.begin() as owned_session:
yield owned_session
```
async with active_factory.begin() as owned_session:
Here, `begin()` is intentionally called on the [`async_sessionmaker`](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.async_sessionmaker.begin), not on an existing `AsyncSession`. The related APIs have different ownership semantics:
- `session_factory()` creates a session whose lifetime the surrounding code must manage; it does not commit automatically.
- `session_factory.begin()` creates a new session and transaction together, commits on successful exit or rolls back on exceptional exit, and then closes the session.
- `session.begin()` manages a transaction on an existing session but does not own or close that session.
The factory form is equivalent in ownership terms to creating a session and then entering that session's transaction:
```python
async with session_factory() as owned_session:
async with owned_session.begin():
yield owned_session
```
@@ -161,7 +201,6 @@ This helper makes transaction ownership follow the same explicit borrow-or-own m
- A supplied session and its active transaction remain caller-owned. The helper does not commit, roll back, or close them.
- Without a supplied session, the helper owns the session and transaction. Successful exit commits; exceptional exit rolls back; either path closes the session.
- A supplied factory overrides cached resolution only when the helper must create a session.
- Use this helper only at a complete operation, service, or use-case boundary. A public CRUD function or repository method may be such a boundary when its optional-session contract explicitly states that omitting the session owns and commits one transaction. Never use it inside a lower-level session-required helper.
- Do not silently begin a transaction on a supplied session. That would make commit ownership depend on hidden helper behavior.
@@ -183,11 +222,11 @@ await run_use_case(...)
## Repository and Function Boundaries
Pass the database URL to repository constructors. The repository stores repeatable database configuration, not mutable session state, and `session_scope()` resolves the cached factory when a standalone operation needs a session. An optional factory override keeps tests independent:
Pass the database URL to repository constructors. The repository stores repeatable database configuration, not mutable session state, and `session_scope()` resolves the cached factory when a standalone operation needs a session:
```python
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.ext.asyncio import AsyncSession
async def find_item(session: AsyncSession, item_id: int) -> Item | None:
@@ -196,13 +235,8 @@ async def find_item(session: AsyncSession, item_id: int) -> Item | None:
class ItemRepository:
def __init__(
self,
database_url: str,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> None:
def __init__(self, database_url: str) -> None:
self.database_url = database_url
self.session_factory = session_factory
async def find(
self,
@@ -213,7 +247,6 @@ class ItemRepository:
async with session_scope(
database_url=self.database_url,
session=session,
session_factory=self.session_factory,
) as active_session:
return await find_item(active_session, item_id)
```
@@ -221,11 +254,11 @@ class ItemRepository:
This split gives each layer one job:
- The repository object identifies its database configuration and creates a session only for a standalone call.
- Production calls reuse the cached factory; tests can inject a factory override.
- Standalone calls reuse the cached factory selected by database URL.
- A caller can pass a session to join an existing unit of work; the repository borrows it.
- The access function owns only the query and requires an existing `AsyncSession`.
- Application wiring supplies the production factory.
- Tests can supply a factory bound to a test engine or call `find_item()` with a transaction-scoped test session.
- Tests can use a test database URL or call `find_item()` with a transaction-scoped test session.
When several repository operations must share one transaction, pass the same session through each call. Put the transaction at the use-case boundary:
@@ -336,7 +369,7 @@ async def create_item(session: AsyncSession = Depends(get_db_session)) -> dict:
## Testing Checks
- Repository constructors accept a test session factory without FastAPI startup.
- Repository constructors accept a test database URL without FastAPI startup.
- Session-taking access functions accept a transaction-scoped test session directly.
- Optional-session tests verify that borrowed sessions remain open and created sessions close.
- Optional-session tests verify that neither path commits implicitly.