Skip to Content

Async Service Layer with __aenter__ / __aexit__ Session Lifecycle

Data Flow

SERVICE LAYER ASYNC SESSION  —  Data Flow

   async with Service() as svc:


   svc.session  ── AsyncSession bound to the unit-of-work


   svc.repo.method()  ──►  repo.session.execute(sql)


        commit()  ── once at __aexit__


        rollback()  ── on any exception


        close()    ── session released back to the pool

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 batching

Why? (Parameter Breakdown)

Common Pitfalls

  1. Stashing the repo in __init__ instead of as a property. Result: bad session reference on first use. Use @property to bind to the open session on demand.
  2. Catching exceptions inside the async with block and not re-raising. This silently swallows business errors AND bypasses the rollback path. If you must catch, re-raise. Or use a narrower try inside, 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 svc

The 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 = False

Key design points:

  1. No-commit-equals-rollback in __aexit__: if you forgot to call commit() the work is rolled back. Safer than leaving a transaction open.
  2. Exception-safe: any exception inside the async with block triggers rollback() before close.
  3. self.committed flag 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.id

get_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.

🛠️ Production Failure Playbook

🚨 Failure 1 — Service commits inside aexit → checkpoint sees stale state

🚨 Failure 2 — Exception in handler skips the rollback

🚨 Failure 3 — Nested services share session → double-commit

Advertisement