12.15. Repository Pattern
Table of Contents
Why Use the Repository Pattern?
In a typical backend application you will write code that:
- Talks to the database using an ORM, for example SQLAlchemy
- Implements business rules and decisions, for example βa user can only create 10 posts per dayβ
If you mix these in the same place, you quickly end up with code that is:
- Hard to test, because tests must hit a real database
- Hard to change, because business logic and database details are tangled
- Hard to reuse, because every part of the code knows about ORM sessions, models, and queries
The Repository Pattern solves this by putting all data access behind a clear interface. Your application code speaks to repositories, not directly to the ORM.
A simple mental picture:
- Without repository:
Handlers β ORM session + queries + business logic all mixed together - With repository:
Handlers β Repository interface β Repository implementation β ORM + database
Your business logic only depends on interfaces, which makes it easier to test and maintain.
Core Ideas of the Repository Pattern
Repository as a Collection-like Interface
A repository represents a collection of objects of a certain type, for example users or orders. Think of it like a specialized in-memory list, but backed by a database.
Typical operations:
add(entity)get(id)list()remove(entity)ordelete(id)- Custom queries, for example
find_by_email(email)
The important part is that your application does not care how these operations are implemented. It just calls methods on a repository.
A very small example in Python-style pseudocode:
class User:
id: int
email: str
name: str
class UserRepository:
def add(self, user: User) -> User:
...
def get(self, user_id: int) -> User | None:
...
def list(self) -> list[User]:
...
def find_by_email(self, email: str) -> User | None:
...This is just an interface or abstract idea. The real work happens in concrete implementations.
Separating Interface from Implementation
The Repository Pattern is about abstraction. You define:
- A repository interface that describes what can be done
- One or more implementations that describe how it is done
You might have:
SqlAlchemyUserRepositorythat uses SQLAlchemy and PostgreSQLInMemoryUserRepositorythat uses a Python list for fast unit testsRedisUserCacheRepositorythat wraps a Redis cache in front of the main repository
The interface stays the same, only the implementation changes.
In Python, this often looks like:
from abc import ABC, abstractmethod
class UserRepository(ABC):
@abstractmethod
def add(self, user: User) -> User:
...
@abstractmethod
def get(self, user_id: int) -> User | None:
...
@abstractmethod
def find_by_email(self, email: str) -> User | None:
...
class SqlAlchemyUserRepository(UserRepository):
def __init__(self, session):
self.session = session
def add(self, user: User) -> User:
self.session.add(user)
self.session.flush()
return user
def get(self, user_id: int) -> User | None:
return self.session.get(User, user_id)
def find_by_email(self, email: str) -> User | None:
return (
self.session.query(User)
.filter(User.email == email)
.one_or_none()
)
Your route handlers and services only know about UserRepository, not about SqlAlchemyUserRepository.
Benefits in a Backend Application
Decoupling Business Logic from Data Access
When you do not use repositories, you often see code like this in a FastAPI endpoint:
def create_user(db_session: Session, user_in: UserCreate):
# ORM code and business logic mixed
existing = (
db_session.query(User)
.filter(User.email == user_in.email)
.one_or_none()
)
if existing:
raise HTTPException(status_code=400, detail="Email already used")
user = User(
email=user_in.email,
name=user_in.name,
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return userThe endpoint:
- Knows about SQLAlchemy
- Knows about how uniqueness is checked
- Knows about transaction handling
With a repository, you can separate roles:
class UserService:
def __init__(self, users: UserRepository):
self.users = users
def register_user(self, email: str, name: str) -> User:
if self.users.find_by_email(email):
raise EmailAlreadyUsed()
user = User(email=email, name=name)
self.users.add(user)
return userThen your FastAPI endpoint becomes:
def create_user(
user_in: UserCreate,
service: UserService = Depends(get_user_service),
):
try:
user = service.register_user(
email=user_in.email,
name=user_in.name,
)
except EmailAlreadyUsed:
raise HTTPException(status_code=400, detail="Email already used")
return userNow:
- Business logic lives in
UserService - Data access is hidden behind
UserRepository - The endpoint is thin and only translates HTTP to service calls
Easier Unit Testing
With a repository interface, you can swap the database implementation for something simpler in tests.
For example, an in-memory repository:
class InMemoryUserRepository(UserRepository):
def __init__(self):
self._items: dict[int, User] = {}
self._id_counter = 1
def add(self, user: User) -> User:
if getattr(user, "id", None) is None:
user.id = self._id_counter
self._id_counter += 1
self._items[user.id] = user
return user
def get(self, user_id: int) -> User | None:
return self._items.get(user_id)
def find_by_email(self, email: str) -> User | None:
return next(
(u for u in self._items.values() if u.email == email),
None,
)Then a unit test for business logic does not need a database at all:
def test_register_user_creates_new_user():
repo = InMemoryUserRepository()
service = UserService(users=repo)
user = service.register_user(
email="test@example.com",
name="Test User",
)
assert user.id is not None
assert repo.get(user.id).email == "test@example.com"You test the logic without worrying about:
- Database connections
- Transactions
- Migrations
- Database performance
This usually makes tests much faster and simpler.
Supporting Multiple Storage Technologies
Because the application only depends on the interface, you can:
- Start with a simple SQLite database and later move to PostgreSQL
- Add a read-side repository that uses a cache like Redis
- Integrate with external systems that act like data sources
For example, two implementations of the same repository:
class SqlAlchemyOrderRepository(OrderRepository):
# uses PostgreSQL via SQLAlchemy
...
class RedisCachedOrderRepository(OrderRepository):
def __init__(self, inner: OrderRepository, redis_client):
self.inner = inner
self.redis = redis_client
def get(self, order_id: int) -> Order | None:
cache_key = f"order:{order_id}"
cached = self.redis.get(cache_key)
if cached:
return deserialize_order(cached)
order = self.inner.get(order_id)
if order:
self.redis.set(cache_key, serialize_order(order))
return order
def add(self, order: Order) -> Order:
order = self.inner.add(order)
cache_key = f"order:{order.id}"
self.redis.set(cache_key, serialize_order(order))
return orderThe service layer does not change. Only the wiring changes when you decide which concrete repository to use.
Typical Repository Design in Python with SQLAlchemy
Below is a small, concrete example that mirrors a real project structure. Details of sessions, models, and transactions belong to other chapters, so we keep those parts simple.
Assume you have a User ORM model.
# models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str]
name: Mapped[str]Now define a repository interface and its SQLAlchemy implementation.
# repositories.py
from abc import ABC, abstractmethod
from sqlalchemy.orm import Session
from .models import User
class UserRepository(ABC):
@abstractmethod
def add(self, user: User) -> User:
...
@abstractmethod
def get(self, user_id: int) -> User | None:
...
@abstractmethod
def list(self) -> list[User]:
...
@abstractmethod
def find_by_email(self, email: str) -> User | None:
...
class SqlAlchemyUserRepository(UserRepository):
def __init__(self, session: Session):
self.session = session
def add(self, user: User) -> User:
self.session.add(user)
# flush to get generated primary key without committing
self.session.flush()
return user
def get(self, user_id: int) -> User | None:
return self.session.get(User, user_id)
def list(self) -> list[User]:
return self.session.query(User).all()
def find_by_email(self, email: str) -> User | None:
return (
self.session.query(User)
.filter(User.email == email)
.one_or_none()
)Your service layer consumes the interface:
# services.py
class UserService:
def __init__(self, users: UserRepository):
self.users = users
def register_user(self, email: str, name: str) -> User:
if self.users.find_by_email(email):
raise EmailAlreadyUsed()
user = User(email=email, name=name)
self.users.add(user)
return userThen in your FastAPI dependency injection, you bind the interface to an implementation:
# dependencies.py
from fastapi import Depends
from sqlalchemy.orm import Session
from .db import get_session
from .repositories import SqlAlchemyUserRepository
from .services import UserService
def get_user_repository(
session: Session = Depends(get_session),
) -> SqlAlchemyUserRepository:
return SqlAlchemyUserRepository(session=session)
def get_user_service(
repo = Depends(get_user_repository),
) -> UserService:
return UserService(users=repo)
When you import get_user_service into routes, FastAPI will create SqlAlchemyUserRepository instances for each request automatically.
Common Variations of the Repository Pattern
One Repository per Aggregate or Entity
In many systems you will have:
UserRepositoryOrderRepositoryProductRepository
Each repository handles its own entity type. Methods in each repository are specific and meaningful to that entity.
Example repository methods:
| Entity | Example methods |
|---|---|
| User | find_by_email, list_active, search |
| Order | list_by_user, list_pending, find_paid |
| Product | search_by_name, list_in_category |
The names express intent, not implementation details like join or filter.
Generic Base Repository
Some teams create a base repository with generic CRUD methods and then inherit from it.
from typing import Generic, TypeVar, Type
from sqlalchemy.orm import Session
from .models import Base
T = TypeVar("T", bound=Base)
class SqlAlchemyRepository(Generic[T]):
model_class: Type[T]
def __init__(self, session: Session):
self.session = session
def add(self, entity: T) -> T:
self.session.add(entity)
self.session.flush()
return entity
def get(self, entity_id: int) -> T | None:
return self.session.get(self.model_class, entity_id)
def list(self) -> list[T]:
return self.session.query(self.model_class).all()
class UserRepository(SqlAlchemyRepository[User]):
model_class = User
def find_by_email(self, email: str) -> User | None:
return (
self.session.query(User)
.filter(User.email == email)
.one_or_none()
)This reduces repetition for basic operations and lets you add entity-specific queries in each subclass.
Read and Write Separation
Sometimes you may have:
- A read repository for queries, often optimized for performance
- A write repository for changes, often tied closely to business rules
For example:
class UserReadRepository(ABC):
@abstractmethod
def get(self, user_id: int) -> User | None:
...
@abstractmethod
def list(self) -> list[User]:
...
class UserWriteRepository(ABC):
@abstractmethod
def add(self, user: User) -> User:
...
@abstractmethod
def delete(self, user: User) -> None:
...This pattern is useful for complex systems or CQRS architectures, but for beginner projects a single repository per entity is usually enough.
Practical Examples of Using Repositories
Example: Creating and Fetching a Product
Define a product repository:
class ProductRepository(ABC):
@abstractmethod
def add(self, product: Product) -> Product:
...
@abstractmethod
def get(self, product_id: int) -> Product | None:
...
@abstractmethod
def list_in_stock(self) -> list[Product]:
...
class SqlAlchemyProductRepository(ProductRepository):
def __init__(self, session: Session):
self.session = session
def add(self, product: Product) -> Product:
self.session.add(product)
self.session.flush()
return product
def get(self, product_id: int) -> Product | None:
return self.session.get(Product, product_id)
def list_in_stock(self) -> list[Product]:
return (
self.session.query(Product)
.filter(Product.quantity > 0)
.all()
)In a service:
class ProductService:
def __init__(self, products: ProductRepository):
self.products = products
def create_product(self, name: str, price: float, quantity: int) -> Product:
product = Product(
name=name,
price=price,
quantity=quantity,
)
self.products.add(product)
return product
def list_available_products(self) -> list[Product]:
return self.products.list_in_stock()In a FastAPI route:
@router.post("/products")
def create_product(
data: ProductCreate,
service: ProductService = Depends(get_product_service),
):
product = service.create_product(
name=data.name,
price=data.price,
quantity=data.quantity,
)
return product
@router.get("/products")
def list_products(
service: ProductService = Depends(get_product_service),
):
return service.list_available_products()
You can now unit test ProductService with an in-memory ProductRepository without any web framework or database.
Transaction Handling and Repositories
Repositories often interact with database transactions. There are two common approaches:
- The unit of work pattern handles the transaction and passes a session into repositories.
- The repository itself begins and commits or rolls back transactions.
In modern applications, the first approach is more common. The repository just uses the session it receives. A higher-level component controls when to commit.
Example with a simple unit of work:
class UnitOfWork(ABC):
users: UserRepository
@abstractmethod
def commit(self) -> None:
...
@abstractmethod
def rollback(self) -> None:
...
class SqlAlchemyUnitOfWork(UnitOfWork):
def __init__(self, session_factory):
self.session_factory = session_factory
def __enter__(self):
self.session = self.session_factory()
self.users = SqlAlchemyUserRepository(self.session)
return self
def __exit__(self, exc_type, exc, tb):
if exc:
self.rollback()
else:
self.commit()
self.session.close()
def commit(self) -> None:
self.session.commit()
def rollback(self) -> None:
self.session.rollback()Usage in a service:
def register_user(uow: UnitOfWork, email: str, name: str) -> User:
with uow:
if uow.users.find_by_email(email):
raise EmailAlreadyUsed()
user = User(email=email, name=name)
uow.users.add(user)
return user # committed on successful exitYour business logic now uses repositories and unit of work instead of dealing with raw sessions.
When to Use the Repository Pattern
The Repository Pattern is most useful when:
- You have non-trivial business logic that you want to test thoroughly
- You expect the data access layer to change or become more complex
- You want to keep frameworks and ORMs out of your core domain logic
- You want to use patterns like Domain Driven Design or layered architecture
For very small scripts or simple CRUD prototypes, a full repository layer might feel like extra work. But as projects grow, this separation pays off in clarity and testability.
Key Rules to Remember
Important rules for the Repository Pattern:
- Your business logic should depend on repository interfaces, not concrete ORM implementations.
- Repositories represent collections of aggregate roots or entities and expose intent-based methods like
find_by_email, not raw SQL. - Repository interfaces stay stable while implementations can change to use different databases, caching, or mocking for tests.
- Keep transaction control outside repositories, usually in a unit of work or service layer, so repositories focus on data access, not coordination.
- Tests for business logic should use in-memory or fake repository implementations, not the real database.
If you keep these rules in mind, repositories will help you build backend applications that are easier to change, easier to test, and easier to reason about as they grow.
Views: 9
KAHIBARO