Async Service Layer with __aenter__ / __aexit__ Session Lifecycle
What? (Concept Overview)
The service layer owns the database transaction boundary: it creates the AsyncSession, hands it to a repository, commits the whole unit of work on success, rolls back on exception, and closes the session deterministically. By implementing the async context-manager protocol (__aenter__ / __aexit__), every service call site becomes a clean async with CustomerService() as svc: block, mirroring the lifecycle of a unit-of-work.
Project Context
The FCA Support Agent has BaseService at app/services/base.py and concrete subclasses (CustomerService, AccountService, TransactionService, ConversationService, MessageService, ProductService, FAQService, RAGService, SecurityService, CacheService). The bulk seed script (app/seed_database.py) relies on this pattern to commit-and-close between customer batches; committing inside one big transaction would defeat idempotency when a single customer row fails.
How? (Quick Reference Blocks)
3.1 The Async Context-Manager Base Class
# app/services/base.py
from contextlib import asynccontextmanager
from app.database import AsyncSessionLocal
class BaseService:
def __init__(self) -> None:
self.session = None
async def __aenter__(self):
# Open a fresh AsyncSession from the global factory on entry.
self.session = AsyncSessionLocal()
# Hand the session to a repository configured for this service.
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
try:
if exc_type is None:
# No exception → commit pending work.
await self.session.commit()
else:
# Exception → roll back ALL pending changes.
await self.session.rollback()
finally:
# Always close the session regardless of commit/rollback.
await self.session.close()
self.session = None
async def commit(self) -> None:
await self.session.commit()
async def rollback(self) -> None:
await self.session.rollback()3.2 Concrete Service & Repository Pairing
# app/services/customer.py (illustrative)
from app.repositories.customer import CustomerRepository
from app.services.base import BaseService
class CustomerService(BaseService):
@property
def repo(self) -> CustomerRepository:
# Built lazily so the session is open by the time it's called.
return CustomerRepository(self.session)
async def create_customer(self, **data) -> "Customer":
return await self.repo.create(data)
async def find_by_email(self, email: str) -> "Customer | None":
return await self.repo.find_by_email(email)3.3 Canonical Call Site
# app/seed_database.py — bulk seed (slot-by-slot commit)
async with CustomerService() as service:
customer = await service.create_customer(**data)
await service.commit() # explicit checkpoint for batchingWhy? (Parameter Breakdown)
__aenter__opens the session,__aexit__closes it — Without the context manager, every call site would need to remember commit/rollback/close in the right order. The protocol makes session lifecycle a language-level guarantee.exc_type is Nonebranch is the commit signal — Python setsexc_type=Noneon a clean exit. By branching on it inside__aexit__, the service commits only when theasync withblock completed without error — eliminating the “half-committed transaction on exception” footgun.finally: await self.session.close()— Guarantees the connection is returned to the pool even if commit/rollback raises. This is the single most important line; without it Postgres can lock up under bursty traffic.- Lazy
repoproperty, not eager init in__init__— The session is None until__aenter__. If you stash the repo in__init__, the repo holds a stale (closed) session and every callAttributeErrors. Lazy resolution keeps the repo bound to a live session. - Explicit
commit()/rollback()methods on the service — Lets callers checkpoint inside a long-running service call (e.g., 1000-row bulk insert) without giving up the lifecycle safety of__aexit__. - One service per unit-of-work, not per request handler — The pattern composes: many services can be entered sequentially in a single request (each committing its own UoW), and a service is trivially mockable by replacing
AsyncSessionLocalin tests.
Common Pitfalls
- Stashing the repo in
__init__instead of as a property. Result: bad session reference on first use. Use@propertyto bind to the open session on demand. - Catching exceptions inside the
async withblock and not re-raising. This silently swallows business errors AND bypasses the rollback path. If you must catch, re-raise. Or use a narrowertryinside, not around the whole block.
Real-World Interview Prep
Q1: How would you make this pattern transactionally compose across multiple services (e.g., create a customer AND an account in one atomic operation)?
A: Two options. Option A — share a session: pass an open AsyncSession into both services’ constructors (override __init__(session) and skip the context manager), wrap both calls in async with UnitOfWork(): parent that owns the session. Option B — outbox pattern: have each service write a row to an event_outbox table; a background worker drains the outbox atomically. Option A is simpler; Option B is mandatory when the two services live in different processes (microservices). Choose A when services share a process.
Q2: How do you test a service that opens its own session?
A: Override the global AsyncSessionLocal factory in a pytest fixture:
@pytest.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return async_sessionmaker(engine, expire_on_commit=False)
@pytest.fixture
async def customer_service(monkeypatch, session_factory):
monkeypatch.setattr("app.services.base.AsyncSessionLocal", session_factory)
async with CustomerService() as svc:
yield svcThe monkeypatch.setattr is mandatory — AsyncSessionLocal is referenced at import time inside BaseService.__aenter__.
Q3: Why split service and repository instead of letting services open sessions per call?
A: Three reasons. (1) Testability — repository tests don’t need a service, service tests don’t need SQL. (2) Composability — you can stack two repos in one service without nesting transactions. (3) Single-transaction semantics — a service is “one unit-of-work”; merging repos into services produces a god-class per resource that mixes persistence with business logic, and impossible-to-reason-about error paths. The discipline mirrors the Hexagonal/Ports-and-Adapters model where services are “use cases” and repos are “ports”.
Top-to-Bottom Code Walkthrough (app/services/base.py + concrete services)
The “async service layer” pattern fixes the biggest bug in web apps: forgetting to commit, or commit then forget to close. It uses __aenter__/__aexit__ to make a session a contextual resource with automatic lifecycle.
BaseService (app/services/base.py)
class BaseService:
def __init__(self):
self.session: AsyncSession | None = None
self.committed = False
async def __aenter__(self):
self.session = AsyncSessionLocal()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
try:
if exc_type is not None:
await self.session.rollback()
self.committed = False
elif not self.committed:
await self.session.rollback() # safety: no commit == rollback
finally:
await self.session.close()
async def commit(self) -> None:
await self.session.commit()
self.committed = True
async def rollback(self) -> None:
await self.session.rollback()
self.committed = FalseKey design points:
- No-commit-equals-rollback in
__aexit__: if you forgot to callcommit()the work is rolled back. Safer than leaving a transaction open. - Exception-safe: any exception inside the
async withblock triggersrollback()before close. self.committedflag lets us tell apart a successful explicit commit from an unintentional no-op.
Concrete example: CustomerService
class CustomerService(BaseService):
async def create_customer(self, **data):
repo = CustomerRepository(self.session, Customer)
hsh = security_service.get_password_hash(data.pop("password"))
data["hashed_password"] = hsh
return await repo.create(data)The service owns the session because: (a) it owns the transaction boundary, (b) it can do multiple repo calls in one transaction (e.g., create customer + log audit event), (c) rollback is at the service level, not per repo.
Multi-step example: AccountService.create_with_initial_deposit
class AccountService(BaseService):
async def create_with_initial_deposit(self, account_data, deposit_amount):
account_repo = AccountRepository(self.session, Account)
txn_repo = TransactionRepository(self.session, Transaction)
account = await account_repo.create(account_data)
await txn_repo.create({
"account_id": account.id,
"amount": deposit_amount,
"description": "Opening deposit",
})
# Single commit for both — atomic.
await self.commit()If txn_repo.create fails, the account INSERT is rolled back. No orphan accounts. The unit-of-work owns the entire operation.
Usage pattern in route handlers
@router.post("/customers")
async def create_customer(
body: CustomerBody,
db: AsyncSession = Depends(get_db), # FALLBACK — not used
):
async with CustomerService() as svc:
customer = await svc.create_customer(**body.dict())
await svc.commit()
return customer.idget_db (the FastAPI dependency) is technically available but services don’t use it — they own the session. Cleaner ownership.
Common Pitfalls
Forgetting to call commit() — the __aexit__ rollout rolls back. If you want persistence, commit before exit.
Catching the exception inside the async with and not re-raising — __aexit__ won’t see the exception, won’t rollback. Always re-raise.
Calling await self.session.close() directly bypasses the safety net. Use async with exclusively.
Real-World Interview Prep
Q1: Why __aenter__/__aexit__ instead of a with body that creates and closes the session itself?
A: Inheritance. CustomerService(BaseService) doesn’t need to redefine session lifecycle — just override methods. The base class enforces the contract.
Q2: How do you test a service that owns its session?
A: Subclass BaseService, override __aenter__ to yield a session bound to a Postgres test container. Replace AsyncSessionLocal in the test conftest. The service code is unchanged.
Q3: What happens if __aexit__’s rollback itself raises?
A: A “rollback failed” exception shadows the original exception — very confusing. Wrap the rollback: try: await self.session.rollback() except Exception: log_and_continue. Always close, never re-raise during teardown.