Applying SOLID Principles and Design Patterns in FastAPI for Clean Architecture
This article explains how to apply SOLID principles and common design patterns such as DAO, service layer, and dependency inversion to FastAPI, demonstrating code examples that separate responsibilities, improve testability, and create a clean, maintainable backend architecture.
FastAPI: SOLID Principles and Design Patterns
FastAPI has become popular for building Python APIs due to its simplicity, speed, and static‑type support. To write clean, maintainable, and scalable code, applying SOLID principles and common design patterns such as DAO, service layer, and dependency injection is essential.
SOLID Principles Applied to FastAPI
1. Single Responsibility Principle (SRP)
Each module or class should handle only one part of the software’s functionality. In FastAPI, route functions should delegate business logic to a service and only manage request/response handling.
Code that violates SRP:
<code>from fastapi import APIRouter
from app.models.user import UserCreate, UserRead
from app.db import database
router = APIRouter()
@router.post("/users", response_model=UserRead)
async def create_user(user: UserCreate):
if not user.email or not user.password:
raise ValueError("Email and password are required.")
existing_user = database.fetch_one(
"SELECT * FROM users WHERE email = :email", {"email": user.email})
if existing_user:
raise ValueError("User already exists.")
new_user_id = database.execute(
"INSERT INTO users (email, password) VALUES (:email, :password)",
{"email": user.email, "password": user.password})
new_user = database.fetch_one(
"SELECT * FROM users WHERE id = :id", {"id": new_user_id})
return new_user
</code>This endpoint mixes input validation, existence check, persistence, and response construction, making it hard to maintain.
SRP‑compliant code:
<code>from app.models.user import UserCreate, UserDB
from app.db import database
class UserRepository:
def __init__(self, db_session):
self.db_session = db_session
async def get_user_by_email(self, email: str) -> UserDB:
query = "SELECT * FROM users WHERE email = :email"
return await self.db_session.fetch_one(query, {"email": email})
async def add_user(self, user_data: UserCreate) -> int:
query = "INSERT INTO users (email, password) VALUES (:email, :password) RETURNING id"
values = {"email": user_data.email, "password": user_data.password}
new_user_id = await self.db_session.execute(query, values)
return new_user_id
async def get_user_by_id(self, user_id: int) -> UserDB:
query = "SELECT * FROM users WHERE id = :id"
return await self.db_session.fetch_one(query, {"id": user_id})
class UserService:
def __init__(self, user_repository: UserRepository):
self.user_repository = user_repository
async def validate_user_data(self, user_data: UserCreate) -> None:
if not user_data.email or not user_data.password:
raise ValueError("Email and password are required.")
async def check_user_exists(self, email: str) -> None:
existing_user = await self.user_repository.get_user_by_email(email)
if existing_user:
raise ValueError("User already exists.")
async def create_user(self, user_data: UserCreate) -> UserRead:
await self.validate_user_data(user_data)
await self.check_user_exists(user_data.email)
new_user_id = await self.user_repository.add_user(user_data)
return await self.user_repository.get_user_by_id(new_user_id)
from fastapi import APIRouter, Depends
from app.models.user import UserCreate, UserRead
from app.services.user_service import UserService
from app.routers.dependencies import get_user_service
router = APIRouter()
@router.post("/users", response_model=UserRead)
async def create_user(user: UserCreate, user_service: UserService = Depends(get_user_service)):
return await user_service.create_user(user)
</code>Now each component has a single responsibility: the router handles HTTP, the service contains business rules, and the repository deals with data access.
2. Dependency Inversion Principle (DIP)
DIP states that high‑level modules should not depend on low‑level modules; both should depend on abstractions.
In the previous example UserService directly depends on the concrete UserRepository , violating DIP. Introducing an abstract repository interface removes this coupling.
<code>from abc import ABC, abstractmethod
from app.models.user import UserCreate, UserRead
class IUserRepository(ABC):
@abstractmethod
async def get_user_by_email(self, email: str) -> UserRead:
pass
@abstractmethod
async def add_user(self, user_data: UserCreate) -> int:
pass
@abstractmethod
async def get_user_by_id(self, user_id: int) -> UserRead:
pass
</code>The concrete UserRepository now implements IUserRepository :
<code>from app.models.user import UserCreate, UserRead
from app.db import database
from app.repositories.user_repository_interface import IUserRepository
class UserRepository(IUserRepository):
def __init__(self, db_session):
self.db_session = db_session
async def get_user_by_email(self, email: str) -> UserRead:
query = "SELECT * FROM users WHERE email = :email"
return await self.db_session.fetch_one(query, {"email": email})
async def add_user(self, user_data: UserCreate) -> int:
query = "INSERT INTO users (email, password) VALUES (:email, :password) RETURNING id"
values = {"email": user_data.email, "password": user_data.password}
return await self.db_session.execute(query, values)
async def get_user_by_id(self, user_id: int) -> UserRead:
query = "SELECT * FROM users WHERE id = :id"
return await self.db_session.fetch_one(query, {"id": user_id})
</code>Finally, UserService depends on the abstraction:
<code>from app.models.user import UserCreate, UserRead
from app.repositories.user_repository_interface import IUserRepository
class UserService:
def __init__(self, user_repository: IUserRepository):
self.user_repository = user_repository
async def validate_user_data(self, user_data: UserCreate) -> None:
if not user_data.email or not user_data.password:
raise ValueError("Email and password are required.")
async def check_user_exists(self, email: str) -> None:
existing_user = await self.user_repository.get_user_by_email(email)
if existing_user:
raise ValueError("User already exists.")
async def create_user(self, user_data: UserCreate) -> UserRead:
await self.validate_user_data(user_data)
await self.check_user_exists(user_data.email)
new_user_id = await self.user_repository.add_user(user_data)
return await self.user_repository.get_user_by_id(new_user_id)
</code>Applying DIP decouples business logic from concrete data‑access implementations, improving flexibility, testability, and maintainability.
Design Patterns Used
1. Data Access Object (DAO) Pattern
The DAO pattern separates data‑access logic from business logic. In the example, UserRepository acts as the DAO, encapsulating all CRUD operations for the user entity.
2. Service Layer
The service layer groups business rules. UserService contains validation, existence checks, and orchestrates repository calls, keeping controllers thin.
Conclusion
By following SOLID principles and employing DAO and service‑layer patterns in FastAPI, you can build robust, flexible, and maintainable APIs that adapt easily to future changes.
Code Mala Tang
Read source code together, write articles together, and enjoy spicy hot pot together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.