Cheatsheet for repository / UoW patterns. Where you’d use them, where you wouldn’t.

When to use a repository

  • You want to test handlers without a DB.
  • Persistence may change (DB → ES → API).
  • The team needs a strict boundary between domain and persistence.

When not:

  • Small CRUD apps — Session is fine as a “repository.”
  • Tight coupling to SQL features is OK.

Basic repository

class UserRepository:
    def __init__(self, session: AsyncSession):
        self.session = session
    
    async def get(self, id: int) -> User | None:
        return await self.session.get(User, id)
    
    async def by_email(self, email: str) -> User | None:
        return await self.session.scalar(select(User).where(User.email == email))
    
    async def list_(self, limit: int = 20, cursor: int = 0) -> list[User]:
        stmt = (
            select(User)
            .where(User.id > cursor)
            .order_by(User.id)
            .limit(limit)
        )
        return list((await self.session.execute(stmt)).scalars())
    
    async def add(self, user: User) -> User:
        self.session.add(user)
        await self.session.flush()
        return user
    
    async def delete(self, id: int) -> None:
        user = await self.session.get(User, id)
        if user:
            await self.session.delete(user)

DI in FastAPI

async def get_user_repo(db: AsyncSession = Depends(get_db)) -> UserRepository:
    return UserRepository(db)

@app.get("/users/{id}")
async def get_user(id: int, repo: UserRepository = Depends(get_user_repo)):
    user = await repo.get(id)
    if not user:
        raise HTTPException(404)
    return user

Generic base repo

from typing import TypeVar, Generic, Type

T = TypeVar("T", bound=Base)

class BaseRepository(Generic[T]):
    model: Type[T]
    
    def __init__(self, session: AsyncSession):
        self.session = session
    
    async def get(self, id: int) -> T | None:
        return await self.session.get(self.model, id)
    
    async def add(self, obj: T) -> T:
        self.session.add(obj)
        await self.session.flush()
        return obj
    
    async def delete(self, id: int) -> None:
        obj = await self.get(id)
        if obj:
            await self.session.delete(obj)
    
    async def list_(self, *, limit: int = 20, **filters) -> list[T]:
        stmt = select(self.model).limit(limit)
        for k, v in filters.items():
            stmt = stmt.where(getattr(self.model, k) == v)
        return list((await self.session.execute(stmt)).scalars())

class UserRepository(BaseRepository[User]):
    model = User
    
    async def by_email(self, email: str) -> User | None:
        return await self.session.scalar(select(User).where(User.email == email))

Unit of Work

class UnitOfWork:
    def __init__(self, session_factory):
        self.session_factory = session_factory
    
    async def __aenter__(self):
        self.session = self.session_factory()
        self.users = UserRepository(self.session)
        self.posts = PostRepository(self.session)
        return self
    
    async def __aexit__(self, exc_type, exc, tb):
        if exc:
            await self.session.rollback()
        else:
            await self.session.commit()
        await self.session.close()

# Usage
async with UnitOfWork(AsyncSessionLocal) as uow:
    user = User(email="[email protected]")
    await uow.users.add(user)
    post = Post(author_id=user.id, title="hi")
    await uow.posts.add(post)
# auto-commit on exit

Service layer (business logic)

class UserService:
    def __init__(self, uow: UnitOfWork):
        self.uow = uow
    
    async def register(self, data: UserCreate) -> User:
        async with self.uow as uow:
            if await uow.users.by_email(data.email):
                raise EmailTaken
            user = User(email=data.email, hashed_password=hash_pw(data.password))
            await uow.users.add(user)
            return user

async def get_uow() -> UnitOfWork:
    return UnitOfWork(AsyncSessionLocal)

async def get_user_service(uow: UnitOfWork = Depends(get_uow)) -> UserService:
    return UserService(uow)

@app.post("/users", response_model=UserOut)
async def register(data: UserCreate, svc: UserService = Depends(get_user_service)):
    return await svc.register(data)

Testing repos

@pytest.fixture
async def repo(db_session):
    return UserRepository(db_session)

async def test_create(repo, db_session):
    u = User(email="[email protected]")
    await repo.add(u)
    await db_session.commit()
    fetched = await repo.get(u.id)
    assert fetched.email == "[email protected]"

Faked repo in service tests

class FakeUserRepo:
    def __init__(self):
        self.store = {}
    async def by_email(self, email):
        return next((u for u in self.store.values() if u.email == email), None)
    async def add(self, user):
        user.id = len(self.store) + 1
        self.store[user.id] = user
        return user

Tradeoffs

RepositoryDirect Session
TestabilityEasier (fake repo)Harder (real DB)
CouplingLowerHigher
BoilerplateMoreLess
Use of SQL featuresWrappedDirect
Team sizeHelpful at scaleLean for small

For most FastAPI apps: Session-as-repository is fine. Reach for the pattern when boundaries matter.

Common mistakes

  • Generic repo with too-generic methods that hide intent.
  • Repo that exposes the session — defeats the purpose.
  • Service layer that’s just a passthrough — adds tax without value.
  • UoW that doesn’t actually unify transactions.

Read this next

If you want my repository + UoW + service template, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .