🎓 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
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.
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:
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.
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.
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:
8Testing Repositories with Mocks
The Repository pattern makes testing incredibly simple. Mock the repository in your tests to avoid database dependencies:
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:
class UserNotFoundError(Exception):
pass
class EmailAlreadyRegisteredError(Exception):
pass
class InsufficientPermissionsError(Exception):
pass10Advanced Repository Queries
Repositories can handle complex queries, filtering, sorting, and pagination:
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:
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 user13Integration 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
| Pitfall | Solution |
|---|---|
| Repositories with business logic | Keep repositories simple - data access only |
| Services that bypass repositories | Always use repositories for data access |
| Circular dependencies between services | Keep service dependencies one-directional |
| Too many dependencies in routes | Use FastAPI's dependency chaining |
| Mixing async and sync repositories | Pick 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! 🚀