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
@@ -33,7 +33,7 @@ Use the same vocabulary at every layer:
| Update | `update_widget()` | `update()` | Owned transaction | `None` |
| Delete | `delete_widget()` | `delete()` | Owned transaction | `None` |
Functions and repository methods both put domain arguments first. Database configuration, factory overrides, and sessions are keyword-only infrastructure arguments. This keeps call sites analogous and makes ownership choices visible.
Functions and repository methods both put domain arguments first. Database configuration and sessions are keyword-only infrastructure arguments. This keeps call sites analogous and makes ownership choices visible.
---
@@ -62,7 +62,6 @@ Functions are the simplest default when grouping state or behavior in an object
```python
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel import select
from .session import session_scope
@@ -75,12 +74,10 @@ async def create_widget(
*,
database_url: str,
session: AsyncSession | None = None,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> Widget:
async with transaction_scope(
database_url=database_url,
session=session,
session_factory=session_factory,
) as active_session:
widget = Widget(name=name, description=description)
active_session.add(widget)
@@ -93,12 +90,10 @@ async def get_widget(
*,
database_url: str,
session: AsyncSession | None = None,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> Widget | None:
async with session_scope(
database_url=database_url,
session=session,
session_factory=session_factory,
) as active_session:
return await active_session.get(Widget, widget_id)
@@ -109,7 +104,6 @@ async def list_widgets(
offset: int = 0,
limit: int = 100,
session: AsyncSession | None = None,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> list[Widget]:
if offset < 0:
raise ValueError("offset must be non-negative")
@@ -119,7 +113,6 @@ async def list_widgets(
async with session_scope(
database_url=database_url,
session=session,
session_factory=session_factory,
) as active_session:
statement = select(Widget).order_by(Widget.id).offset(offset).limit(limit)
return list(await active_session.scalars(statement))
@@ -132,12 +125,10 @@ async def update_widget(
*,
database_url: str,
session: AsyncSession | None = None,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> Widget | None:
async with transaction_scope(
database_url=database_url,
session=session,
session_factory=session_factory,
) as active_session:
widget = await active_session.get(Widget, widget_id)
if widget is None:
@@ -154,12 +145,10 @@ async def delete_widget(
*,
database_url: str,
session: AsyncSession | None = None,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> Widget | None:
async with transaction_scope(
database_url=database_url,
session=session,
session_factory=session_factory,
) as active_session:
widget = await active_session.get(Widget, widget_id)
if widget is None:
@@ -178,20 +167,14 @@ Update and delete load the row through the same session that mutates it. This av
## Repository Object
A repository can provide a stable domain-facing interface when several callers need the same grouped operations. It stores repeatable database configuration and an optional factory override, never a mutable session. Every method delegates to the analogous function and exposes the same optional-session contract.
A repository can provide a stable domain-facing interface when several callers need the same grouped operations. It stores repeatable database configuration, never a mutable session. Every method delegates to the analogous function and exposes the same optional-session contract.
```python
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import async_sessionmaker
class WidgetRepository:
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 create(
self,
@@ -205,7 +188,6 @@ class WidgetRepository:
description,
database_url=self.database_url,
session=session,
session_factory=self.session_factory,
)
async def get(
@@ -218,7 +200,6 @@ class WidgetRepository:
widget_id,
database_url=self.database_url,
session=session,
session_factory=self.session_factory,
)
async def list(
@@ -233,7 +214,6 @@ class WidgetRepository:
offset=offset,
limit=limit,
session=session,
session_factory=self.session_factory,
)
async def update(
@@ -250,7 +230,6 @@ class WidgetRepository:
description,
database_url=self.database_url,
session=session,
session_factory=self.session_factory,
)
async def delete(
@@ -263,11 +242,10 @@ class WidgetRepository:
widget_id,
database_url=self.database_url,
session=session,
session_factory=self.session_factory,
)
```
The object is intentionally thin. The factory override lets tests supply a maker bound to a test engine without FastAPI startup. A caller-provided session always wins and remains open after the method returns. A standalone operation closes its owned session before returning, so returned objects are detached; load every required scalar, deferred column, and relationship explicitly before the scope exits, and do not mutate those objects expecting persistence.
The object is intentionally thin. Tests can construct it with a test database URL or pass a transaction-scoped test session to individual methods. A caller-provided session always wins and remains open after the method returns. A standalone operation closes its owned session before returning, so returned objects are detached; load every required scalar, deferred column, and relationship explicitly before the scope exits, and do not mutate those objects expecting persistence.
If a read participates in a later write, pass the same session and place both operations inside the explicit transaction. This avoids splitting one use case across sessions and keeps SQLAlchemy's autobegin behavior from obscuring transaction ownership. Add a repository only when its naming, shared query policy, dependency substitution, or domain boundary improves the application. Independent functions remain a valid and often clearer design.
@@ -294,7 +272,6 @@ async def replace_widget(
async with transaction_scope(
database_url=repository.database_url,
session=session,
session_factory=repository.session_factory,
) as active_session:
deleted_widget = await repository.delete(
widget_id,
@@ -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.