Skip to main content

Transactional session management for SQLAlchemy use cases (sync + async).

Project description

fastapi-transactional

PyPI version Python versions License: MIT CI

Transactional session management for SQLAlchemy use cases — sync and async. Built for FastAPI and other apps that follow hexagonal / clean architecture, where a single use case orchestrates multiple repositories and they all need to share one transaction.

@with_transaction
def execute(self, policy_id: str) -> None:
    policy = self.policy_repo.find_by_id(policy_id)
    policy.status = "ACTIVE"
    self.policy_repo.save(policy)
    self.notification_repo.save(Notification(policy_id=policy_id))
    # Commit on success. Rollback on any exception. Both repos share the same session.

Why

In a hexagonal architecture, a use case typically depends on multiple repositories:

class ActivatePolicyUseCase:
    def __init__(self):
        self.policy_repo = PolicyRepository()
        self.notification_repo = NotificationRepository()

If each repository opens its own session, you lose transactional guarantees — a failure in notification_repo.save() won't undo the change in policy_repo.save(). The usual fix is to pass a session around explicitly, which leaks infrastructure into the domain layer.

fastapi-transactional solves this with a ContextVar-based session and a one-line decorator. Every repository inheriting from DatabaseRepository automatically receives the active session before the use case runs.

Install

pip install fastapi-transactional

Requires Python 3.10+ and SQLAlchemy 2.0+.

Quickstart — sync

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from fastapi_transactional import (
    DatabaseRepository, configure, with_transaction,
)

# 1. Configure once at startup.
engine = create_engine("postgresql+psycopg://user:pass@host/db")
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
configure(session_factory=SessionLocal)


# 2. Inherit from DatabaseRepository — `self.db` is injected automatically.
class PolicyRepository(DatabaseRepository):
    def find_by_id(self, policy_id):
        return self.db.get(Policy, policy_id)

    def save(self, policy):
        self.db.add(policy)


# 3. Decorate the use case method.
class ActivatePolicyUseCase:
    def __init__(self):
        self.policy_repo = PolicyRepository()

    @with_transaction
    def execute(self, policy_id: str) -> None:
        policy = self.policy_repo.find_by_id(policy_id)
        policy.status = "ACTIVE"
        self.policy_repo.save(policy)

Quickstart — async

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from fastapi_transactional import (
    AsyncDatabaseRepository, configure, with_async_transaction,
)

engine = create_async_engine("postgresql+asyncpg://user:pass@host/db")
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
configure(async_session_factory=AsyncSessionLocal)


class PolicyRepository(AsyncDatabaseRepository):
    async def find_by_id(self, policy_id):
        return await self.db.get(Policy, policy_id)

    async def save(self, policy):
        self.db.add(policy)


class ActivatePolicyUseCase:
    def __init__(self):
        self.policy_repo = PolicyRepository()

    @with_async_transaction
    async def execute(self, policy_id: str) -> None:
        policy = await self.policy_repo.find_by_id(policy_id)
        policy.status = "ACTIVE"
        await self.policy_repo.save(policy)

You can configure both sync and async factories at the same time — they live in separate ContextVars and don't interfere.

How it works

  1. The decorator opens a session_manager() (sync) or async_session_manager() (async) context.
  2. The manager creates a session from the factory you registered with configure(...) and stores it in a ContextVar.
  3. The decorator scans self for attributes that are DatabaseRepository / AsyncDatabaseRepository instances and binds their .db to the active session.
  4. Your method runs. On clean exit the session commits; on any exception it rolls back.
  5. The session is always closed and removed from the ContextVar, and every repository binding from step 3 is undone.

ContextVar is thread-safe and asyncio-safe — each request handler gets its own slot, so concurrent requests never see each other's sessions.

Repositories cannot outlive their scope

Step 5 matters more than it looks. A SQLAlchemy session stays usable after close(): the next statement silently opens a new transaction on a new pooled connection. So a repository still holding a finished session doesn't fail — it leaks a connection, surfacing much later and far away as:

The garbage collector is trying to clean up non-checked-in connection

Because the binding is undone on exit, self.db raises a RuntimeError naming the repository instead:

class GetPolicy:
    def __init__(self):
        self.repo = PolicyRepo()

    @with_async_transaction
    async def _load(self, policy_id):
        return await self.repo.find_by_id(policy_id)

    async def execute(self, policy_id):
        policy = await self._load(policy_id)
        # RuntimeError: PolicyRepo has no active session. Repository methods
        # must be called from inside a @with_async_transaction method, [...]
        return await self.repo.find_by_id(policy_id)

The fix is to move the read inside a decorated method. Nested scopes restore the outer binding rather than clearing it, so composing use cases keeps working.

Nested use cases

A use case can call another use case without worrying about double transactions:

class Outer:
    def __init__(self):
        self.inner = Inner()

    @with_transaction
    def execute(self):
        self.inner.execute()      # Reuses the outer session.
        self.repo.save(thing)     # Same transaction.
        # Single commit when execute() returns.

When session_manager() sees an existing session bound to the context, it yields that session instead of opening a new one — so the outermost call owns the transaction lifecycle.

Advanced — accessing the session directly

For code that isn't a repository (e.g. a service that needs to run a raw query inside the active transaction):

from fastapi_transactional import get_current_session

def some_service():
    session = get_current_session()
    if session is None:
        raise RuntimeError("Must be called inside a transactional scope")
    session.execute(...)

The async equivalents are get_current_async_session, set_current_async_session, clear_current_async_session.

You can also use the context manager directly without the decorator:

with session_manager() as session:
    session.execute(...)
# Commit happens here. Rollback on exception.

API reference

Symbol Purpose
configure(session_factory=..., async_session_factory=...) Register session factories (call once at startup).
reset() Clear the registered factories (useful in tests).
session_manager() / async_session_manager() Context manager that opens a transactional scope.
with_transaction / with_async_transaction Decorator wrapping a method in a transactional scope and injecting sessions into repositories.
DatabaseRepository / AsyncDatabaseRepository Base class — gives the repo a self.db slot, which raises outside a transactional scope.
get_current_session / set_current_session / clear_current_session Direct access to the sync ContextVar.
get_current_async_session / set_current_async_session / clear_current_async_session Direct access to the async ContextVar.

Tests

pip install -e ".[test]"
pytest --cov=fastapi_transactional

Contributing

Issues and pull requests welcome. See CONTRIBUTING.md.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fastapi_transactional-0.2.0.tar.gz (15.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fastapi_transactional-0.2.0-py3-none-any.whl (13.0 kB view details)

Uploaded Python 3

File details

Details for the file fastapi_transactional-0.2.0.tar.gz.

File metadata

  • Download URL: fastapi_transactional-0.2.0.tar.gz
  • Upload date:
  • Size: 15.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for fastapi_transactional-0.2.0.tar.gz
Algorithm Hash digest
SHA256 b1b099e1aa895efb4a58fc9d6e9558dd72cfcef0bc4ba22901e9648cb0207233
MD5 256a09ba38c1020373f93d067b956b77
BLAKE2b-256 91e25803d5156f9047bb9d6df2f39689c4376190d0fcf479278e27a6c945a815

See more details on using hashes here.

Provenance

The following attestation bundles were made for fastapi_transactional-0.2.0.tar.gz:

Publisher: release.yml on oviladrosa/fastapi-transactional

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file fastapi_transactional-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for fastapi_transactional-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3fcf605ea826e761fb7ca12531bc882a045df348318107ab9b7eac8b94b0d847
MD5 b1c3b1007726d3f5c56b24cb402d6a6f
BLAKE2b-256 8bd183e0156dfc9ecce03b2f9578e79949f20a71238fc758cab6b84fd81deeb9

See more details on using hashes here.

Provenance

The following attestation bundles were made for fastapi_transactional-0.2.0-py3-none-any.whl:

Publisher: release.yml on oviladrosa/fastapi-transactional

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page