Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 046 / 100

Repository pattern in Python: what developers should know

Separate data access from business logic.

Expona Admin7 min read

Most Python backends start with business logic tangled directly into database queries, creating rigid, hard-to-test code that breaks when you switch databases or need to run tests without a live connection. The repository pattern in Python offers a clean separation: a simple abstraction layer that sits between your services and your data store. We'll walk through when it makes sense, how to implement it with SQLAlchemy, and when to skip it entirely.

Why separation of concerns matters

Your business logic shouldn't know whether data comes from PostgreSQL, MongoDB, or a CSV file. Yet in many Python projects, service code directly instantiates ORM sessions, chains query methods, and handles transaction logic. When requirements change—say, switching databases or adding caching—you're forced to rewrite your entire service layer.

The repository pattern creates a boundary. On one side sits your business logic (service classes, domain models). On the other sits data access (queries, persistence). Your service doesn't care how the repository fetches a user; it just asks for one. This separation makes testing faster, refactoring safer, and codebases easier to reason about as they grow.

That said, repositories aren't free. They add indirection and boilerplate. For a simple CRUD application, wrapping every query in a repository method can feel excessive. The key is knowing when the abstraction pays for itself.

The interface — A small class with get, add and list methods

Start with a minimal interface. You don't need to model every possible query; you need the verbs your service actually uses. Most business logic boils down to four operations: fetch one, fetch many, create, and delete. Some repos add update, but immutable-first designs often avoid it.

Here's a simple contract:

python
from abc import ABC, abstractmethod
from typing import List, Optional, Generic, TypeVar

T = TypeVar('T')

class Repository(ABC, Generic[T]):
    @abstractmethod
    def get(self, id: int) -> Optional[T]:
        """Fetch a single entity by primary key."""
        pass

    @abstractmethod
    def list(self) -> List[T]:
        """Fetch all entities."""
        pass

    @abstractmethod
    def add(self, entity: T) -> T:
        """Persist a new entity. Return the saved entity with ID."""
        pass

    @abstractmethod
    def delete(self, id: int) -> None:
        """Remove an entity by primary key."""
        pass

This interface is generic enough to work with any model. It's also honest: it doesn't pretend to handle complex filtering or sorting. If your service needs to find users by email or orders in a date range, add those methods explicitly to concrete implementations.

Now a service class knows only about the repository contract, not SQL:

python
class UserService:
    def __init__(self, repo: Repository[User]):
        self.repo = repo

    def register_user(self, email: str, password: str) -> User:
        # Validation and business logic
        if not self._is_valid_email(email):
            raise ValueError("Invalid email")
        
        user = User(email=email, password_hash=hash_password(password))
        return self.repo.add(user)

    def get_user_profile(self, user_id: int) -> Optional[User]:
        return self.repo.get(user_id)

The service doesn't know if User lives in a database or memory. It just calls the repo and trusts the contract.

SQLAlchemy implementation — Wraps the session and queries

The concrete implementation wraps your ORM. This is where you write the actual SQL (or let SQLAlchemy generate it). Keep queries simple and move complex logic into service or domain layers.

python
from sqlalchemy.orm import Session
from typing import List, Optional

class UserRepository(Repository[User]):
    def __init__(self, session: Session):
        self.session = session

    def get(self, id: int) -> Optional[User]:
        return self.session.query(User).filter(User.id == id).first()

    def list(self) -> List[User]:
        return self.session.query(User).all()

    def add(self, entity: User) -> User:
        self.session.add(entity)
        self.session.commit()
        return entity

    def delete(self, id: int) -> None:
        user = self.get(id)
        if user:
            self.session.delete(user)
            self.session.commit()

Notice the session is injected. This makes testing and managing database connections straightforward. In a web framework like FastAPI, you'd typically create a new session per request and pass it to repositories via dependency injection.

For more complex queries, add methods to the concrete repository:

python
class UserRepository(Repository[User]):
    # ... base methods ...

    def find_by_email(self, email: str) -> Optional[User]:
        return self.session.query(User).filter(User.email == email).first()

    def list_active(self) -> List[User]:
        return self.session.query(User).filter(User.is_active == True).all()

The service that needs these methods declares its dependency on a repository that supports them. If you're using type hints, you might create a richer interface that inherits from the base Repository and adds domain-specific queries.

In-memory fake for tests — Test services without a database

This is where repositories shine. A fake in-memory implementation lets you test business logic without spinning up a database, containers, or fixtures. Tests run fast and are isolated from infrastructure.

python
class FakeUserRepository(Repository[User]):
    def __init__(self):
        self.users: dict[int, User] = {}
        self.next_id = 1

    def get(self, id: int) -> Optional[User]:
        return self.users.get(id)

    def list(self) -> List[User]:
        return list(self.users.values())

    def add(self, entity: User) -> User:
        entity.id = self.next_id
        self.next_id += 1
        self.users[entity.id] = entity
        return entity

    def delete(self, id: int) -> None:
        self.users.pop(id, None)

    def find_by_email(self, email: str) -> Optional[User]:
        for user in self.users.values():
            if user.email == email:
                return user
        return None

Now test the service without any database:

python
def test_register_user():
    repo = FakeUserRepository()
    service = UserService(repo)

    user = service.register_user("alice@example.com", "secret123")

    assert user.id == 1
    assert user.email == "alice@example.com"
    assert repo.get(1) == user


def test_register_duplicate_email():
    repo = FakeUserRepository()
    service = UserService(repo)

    service.register_user("alice@example.com", "secret123")
    
    with pytest.raises(ValueError):
        service.register_user("alice@example.com", "different")

These tests are unit tests: they exercise business logic in isolation. They run in milliseconds. You don't need test databases, migrations, or cleanup. This is the core benefit of the repository pattern for Python teams.

Do not over-abstract — For simple CRUD, using the ORM directly is fine

The repository pattern isn't mandatory. For a small application with straightforward CRUD operations and no complex filtering logic, adding repositories often adds more noise than clarity. Your codebase should reflect its actual needs.

If your entire service layer looks like this:

python
def get_user(session: Session, user_id: int) -> Optional[User]:
    return session.query(User).filter(User.id == user_id).first()

def create_user(session: Session, email: str, password: str) -> User:
    user = User(email=email, password_hash=hash_password(password))
    session.add(user)
    session.commit()
    return user

Then a repository wrapper around these functions adds boilerplate without benefit. Your service code is already separated from business logic. Ship it.

Repositories become valuable when:

  • You have complex business logic that needs testing without a database.
  • You might swap data sources (e.g., migrate from PostgreSQL to DynamoDB).
  • Different parts of your codebase query the same entity in different ways, and you want a single place to evolve those queries.
  • Your team is large enough that standardizing data access patterns prevents bugs.

For a solo project or small team building an MVP, repositories are optional. For a production platform with complex domain logic, they're worth the investment.

Common mistakes

Leaking ORM details into the repository interface. If your Repository base class exposes sqlalchemy.orm.Query objects or ORM-specific exceptions, you've defeated the purpose. The interface should describe what you're doing (get, add, list), not how.

Repositories that are too thin. A repository that just wraps a single ORM call adds noise. Keep simple CRUD in the ORM; use repositories when you have repeated query patterns or complex transformations.

Forgetting to test the repository itself. Your fake repository is testable, but so is your real one. Write integration tests against a real (test) database to ensure your SQLAlchemy queries work as expected. Don't just test the fake and assume the real implementation works.

Making repositories do too much. Repositories should fetch and persist entities. Transaction management, caching, event publishing—those belong in service or domain layers. A repository that knows about side effects is hard to reuse.

Key takeaways

  • The repository pattern in Python separates data access logic from business logic, making services testable and database-agnostic.
  • Define a minimal interface (get, list, add, delete) so services depend on contracts, not implementations.
  • SQLAlchemy repositories wrap ORM sessions and queries; inject sessions to keep repositories stateless and testable.
  • Fake in-memory repositories let you unit-test services without a database, running tests in milliseconds.
  • Don't over-abstract: for simple CRUD and small projects, using the ORM directly is fine. Repositories justify themselves as complexity grows.
  • Test both the fake repository and the real SQLAlchemy implementation to catch query bugs early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles