FastAPI

FastAPI CRUD Patterns — Repository & Service Layer Architecture

Thirdy Gayares
18 min read

🎓 What You Will Learn

  • Repository Pattern: Abstract database operations into reusable, testable components
  • Service Pattern: Encapsulate business logic separate from routes
  • Dependency Injection: Decouple components for better testing and maintainability
  • Project Structure: Organize FastAPI projects at scale
  • Testing Strategies: Mock repositories and services for isolated tests
  • Real-World Examples: Complete CRUD application with all patterns
FastAPI 0.100+SQLModelPostgreSQLPytest

1Why Patterns Matter in FastAPI

As your FastAPI application grows, your route handlers become increasingly complex. Mixing database queries, business logic, and validation in routes creates problems: code duplication, hard-to-test code, difficult modifications, and tight coupling between components and their implementations.

Design Pattern Philosophy: Patterns are proven solutions to recurring problems. Repository and Service patterns have been battle-tested across thousands of applications.

2Understanding the Repository Pattern

The Repository pattern acts as an in-memory collection-like interface to your data. Instead of routes directly querying the database, they ask the repository for data.

1Routes ask the repository for data
2Repository handles database queries
3Database returns raw data
4Repository shapes data for routes
app/repositories/user_repository.py
from sqlmodel import Session, select
from app.models import User

class UserRepository:
    def __init__(self, session: Session):
        self.session = session

    def get_by_id(self, user_id: int) -> User:
        return self.session.exec(
            select(User).where(User.id == user_id)
        ).first()

    def get_all(self) -> list[User]:
        return self.session.exec(select(User)).all()

    def create(self, user: User) -> User:
        self.session.add(user)
        self.session.commit()
        self.session.refresh(user)
        return user

🎯 Interactive Demo: Repository Pattern in Action

Repository Pattern Layers:

1Database Layer: SQLAlchemy/SQLModel models
2Repository: Abstract CRUD operations
3Service: Business logic using repositories
4Routes: FastAPI endpoints calling services
✅ This pattern keeps your FastAPI code testable, maintainable, and scalable!

3Implementing Domain-Specific Repositories

Create separate repository classes for each domain entity (User, Product, Order, etc.). Each repository handles all data operations for that specific entity, keeping concerns separate and code organized.

4Building the Service Layer

The Service layer sits above repositories and contains all business logic. Services coordinate multiple repositories, validate data, handle transactions, and implement business rules.

app/services/user_service.py
from app.repositories.user_repository import UserRepository
from app.models import User
from app.schemas import UserCreate

class UserService:
    def __init__(self, user_repo: UserRepository):
        self.user_repo = user_repo

    def register_user(self, user_data: UserCreate) -> User:
        # Business logic: validate email uniqueness
        existing = self.user_repo.get_by_email(user_data.email)
        if existing:
            raise ValueError("Email already registered")

        # Create new user
        user = User(**user_data.dict())
        return self.user_repo.create(user)

    def get_user_profile(self, user_id: int) -> dict:
        user = self.user_repo.get_by_id(user_id)
        if not user:
            raise ValueError("User not found")
        return {"id": user.id, "email": user.email}

5Implementing Dependency Injection

Use FastAPI's Depends to inject repositories and services into routes. This decouples routes from implementations and makes testing easy.

app/routes/users.py
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.database import get_session
from app.repositories.user_repository import UserRepository
from app.services.user_service import UserService

router = APIRouter()

def get_user_service(session: Session = Depends(get_session)) -> UserService:
    user_repo = UserRepository(session)
    return UserService(user_repo)

@router.post("/users/register")
def register_user(
    user_data: UserCreate,
    service: UserService = Depends(get_user_service)
):
    return service.register_user(user_data)

6Writing Routes with Dependency Injection

Routes become simple and focused: they receive data, call the service, and return results. All complex logic is handled by services, all data access by repositories.

7Organizing Your Project Structure

A well-organized FastAPI project with these patterns looks like:

app/
├── models/
│ ├── user.py
│ └── product.py
├── repositories/
│ ├── user_repository.py
│ └── product_repository.py
├── services/
│ ├── user_service.py
│ └── product_service.py
├── routes/
│ ├── users.py
│ └── products.py
├── schemas/
│ ├── user.py
│ └── product.py
├── database.py
└── main.py

8Testing Repositories with Mocks

The Repository pattern makes testing incredibly simple. Mock the repository in your tests to avoid database dependencies:

tests/test_user_service.py
from unittest.mock import Mock, MagicMock
from app.services.user_service import UserService
from app.models import User

def test_register_user_success():
    # Mock repository
    mock_repo = Mock()
    mock_repo.get_by_email.return_value = None
    mock_repo.create.return_value = User(
        id=1, email="[email protected]"
    )

    # Create service with mock
    service = UserService(mock_repo)

    # Test
    result = service.register_user(
        UserCreate(email="[email protected]", password="123")
    )

    assert result.id == 1
    mock_repo.create.assert_called_once()

9Error Handling in Services

Services are the perfect place to handle application-level errors. Create custom exceptions and handle them at the service layer:

app/exceptions.py
class UserNotFoundError(Exception):
    pass

class EmailAlreadyRegisteredError(Exception):
    pass

class InsufficientPermissionsError(Exception):
    pass

10Advanced Repository Queries

Repositories can handle complex queries, filtering, sorting, and pagination:

app/repositories/user_repository.py
def get_filtered(
    self,
    skip: int = 0,
    limit: int = 10,
    email: str = None,
    is_active: bool = None
) -> list[User]:
    query = select(User)

    if email:
        query = query.where(User.email.contains(email))
    if is_active is not None:
        query = query.where(User.is_active == is_active)

    return self.session.exec(
        query.offset(skip).limit(limit)
    ).all()

11Handling Transactions and Data Consistency

Services manage transactions. If multiple operations must succeed together, wrap them in a transaction:

Transaction Best Practice: Handle transactions at the service layer where business logic lives. If one operation fails, rollback the entire transaction.

12Caching Patterns with Repositories

Add caching to repositories for frequently accessed data:

app/repositories/user_repository.py
from functools import lru_cache

class UserRepository:
    def __init__(self, session: Session):
        self.session = session
        self._cache = {}

    def get_by_id(self, user_id: int) -> User:
        # Check cache first
        if user_id in self._cache:
            return self._cache[user_id]

        user = self.session.exec(
            select(User).where(User.id == user_id)
        ).first()

        if user:
            self._cache[user_id] = user

        return user

13Integration Testing with Repositories

For integration tests, use a test database and real repositories to verify end-to-end behavior:

Testing Strategy: Use unit tests with mocks for services, integration tests with real database for repositories. This catches both logic errors and data access issues.

14Common Pitfalls and How to Avoid Them

PitfallSolution
Repositories with business logicKeep repositories simple - data access only
Services that bypass repositoriesAlways use repositories for data access
Circular dependencies between servicesKeep service dependencies one-directional
Too many dependencies in routesUse FastAPI's dependency chaining
Mixing async and sync repositoriesPick one and stick with it

Don't over-complicate: Start simple with these patterns. Add complexity only when you need it. A small app can skip services initially and go straight from routes to repositories.

15Resources & What's Next

You've learned the fundamental patterns for scalable FastAPI applications. These patterns work at any scale, from small projects to enterprise applications.

Next Topics to Explore: Consider learning pagination strategies (Limit-offset, Cursor), async repository operations with async/await, event-driven architectures with message queues, and CQRS patterns for complex domains.

Congratulations! You now have the knowledge to build scalable, maintainable FastAPI applications. Your code will be easier to test, refactor, and extend. Happy coding! 🚀

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.