| name | fastapi-backend-guidelines |
| description | FastAPI backend development guidelines for Python async applications. Domain-Driven Design with FastAPI routers, SQLModel/SQLAlchemy ORM, repository pattern, service layer, async/await patterns, Pydantic validation, and error handling. Use when creating APIs, routes, services, repositories, or working with backend code. |
FastAPI Backend Development Guidelines
Purpose
Comprehensive guide for modern FastAPI development with async Python, emphasizing Domain-Driven Design, layered architecture (Router โ Service โ Repository), SQLModel ORM, and async best practices.
When to Use This Skill
- Creating new API routes or endpoints
- Building domain services and business logic
- Implementing repositories for data access
- Setting up database models with SQLModel
- Async/await patterns and error handling
- Organizing backend code with DDD
- Pydantic validation and DTOs
- Python async best practices
Quick Start
New API Route Checklist
Creating an API endpoint? Follow this checklist:
New Domain Feature Checklist
Creating a new domain? Set up this structure:
Project Structure Quick Reference
Your YGS backend structure:
backend/
backend/
main.py # FastAPI app creation with lifespan
api/
v1/
routers/ # API route handlers
admin.py # Dashboard, members, matching
auth.py # Login, signup, OAuth
match.py # Match weeks, history
user.py # User management
upload.py # S3 presigned URLs
domain/ # Domain-Driven Design
user/
model.py # User, UserProfile, UserLifestyle, etc.
repository.py # UserRepository, UserDataLoader
service.py # UserService
enums.py # All domain enums
auth/
service.py # AuthService (JWT, Firebase, Kakao)
repository.py # AuthRepository
admin/
model.py # ConsultSchedule
service.py # AdminService
repository.py # AdminRepository
matching_service.py # MatchingService (scoring algorithm)
match/
model.py # MatchWeek, MatchHistory, MatchFeedback
service.py # MatchService
repository.py # MatchRepository
llm/
matching_service.py # LLM-enhanced matching
shared/
base_repository.py # Generic BaseRepository
dtos/ # Pydantic DTOs
admin.py # Dashboard, member DTOs
auth.py # Login, signup, OAuth DTOs
match.py # Match week, history DTOs
user.py # User, profile DTOs
llm_match.py # LLM matching DTOs
db/
orm.py # Read/Write session management
core/
config.py # Pydantic Settings configuration
middleware/ # Middleware
error_handler.py # ErrorHandlerMiddleware
admin_auth.py # Admin authentication
utils/ # Utilities
s3.py # S3 presigned URLs
s3_private.py # Private user data S3
firebase.py # Firebase verification
password.py # bcrypt hashing
excel.py # Excel export
error/ # Custom exceptions
__init__.py # AppException, NotFoundError, etc.
Common Imports Cheatsheet
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlmodel import select, or_, and_, col
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy import func, desc
from sqlalchemy.orm import selectinload
from backend.db.orm import get_write_session_dependency, get_read_session_dependency
from pydantic import BaseModel, Field, field_validator, EmailStr
from backend.domain.user.model import User, UserProfile
from backend.domain.user.service import UserService
from backend.dtos.user import UserResponse, UserCreateRequest
from backend.error import NotFoundError, ForbiddenError, UnauthorizedError
from typing import List, Optional, Dict, Any
from ulid import ULID
Topic Guides
๐๏ธ Layered Architecture
Three-Layer Pattern:
- Router Layer: API endpoints, request validation, response formatting
- Service Layer: Business logic, orchestration, domain rules
- Repository Layer: Data access, queries, database operations
Key Concepts:
- Routers call Services (never Repositories directly)
- Services orchestrate business logic
- Repositories handle all database operations
- Each layer has clear responsibilities
- Async/await throughout the stack
- Read/Write session separation
๐ Complete Guide: resources/layered-architecture.md
๐ฃ๏ธ API Routes & Routers
PRIMARY PATTERN: FastAPI Routers
- Create routers in
backend/api/v1/routers/
- Use dependency injection for sessions
- Use
get_read_session_dependency for GET requests
- Use
get_write_session_dependency for POST/PATCH/DELETE
- Follow REST conventions
- Use appropriate HTTP methods and status codes
- Async route handlers
Router Structure:
from fastapi import APIRouter, Depends
from sqlmodel.ext.asyncio.session import AsyncSession
from backend.db.orm import get_read_session_dependency, get_write_session_dependency
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}")
async def get_user(
user_id: str,
session: AsyncSession = Depends(get_read_session_dependency),
) -> UserResponse:
service = UserService(session)
return await service.get_user(user_id)
@router.post("", status_code=201)
async def create_user(
request: UserCreateRequest,
session: AsyncSession = Depends(get_write_session_dependency),
) -> UserResponse:
service = UserService(session)
return await service.create_user(request)
๐ Complete Guide: resources/api-routes.md
๐๏ธ Database & ORM
SQLModel + SQLAlchemy:
- SQLModel for models (combines SQLAlchemy + Pydantic)
- Async sessions with asyncpg driver
- Read/Write session separation with caching
- Repository pattern for all queries
- ULID-based ID generation with prefixes
Model Pattern:
from sqlmodel import SQLModel, Field, Column, DateTime, Text
from datetime import datetime, timezone
from ulid import ULID
def generate_user_id() -> str:
"""Generate user ID with prefix."""
return f"usr_{ULID()}"
class User(SQLModel, table=True):
__tablename__ = "user"
id: str = Field(
default_factory=generate_user_id,
primary_key=True,
max_length=30,
)
phone: str = Field(sa_column=Column(Text, nullable=False, unique=True))
name: str = Field(sa_column=Column(Text, nullable=False))
gender: GenderEnum = Field(sa_column=Column(Text, nullable=False))
deleted_at: Optional[datetime] = Field(
sa_column=Column(DateTime(timezone=True), nullable=True),
default=None,
)
created_at: datetime = Field(
sa_column=Column(DateTime(timezone=True), nullable=False),
default_factory=lambda: datetime.now(tz=timezone.utc),
)
updated_at: datetime = Field(
sa_column=Column(DateTime(timezone=True), nullable=False),
default_factory=lambda: datetime.now(tz=timezone.utc),
)
๐ Complete Guide: resources/database-orm.md
๐ฆ Domain-Driven Design
Domain Organization:
- Each domain in
backend/domain/{name}/
- Contains:
model.py, repository.py, service.py
- Clear separation of concerns
- Business logic in services
- Data access in repositories
Your Domains:
- user: User management (User, UserProfile, UserLifestyle, UserPreference, etc.)
- auth: Authentication (JWT, Firebase, Kakao OAuth)
- admin: Admin dashboard, member management, consultations
- match: Match weeks, history, feedback
- llm: LLM-enhanced matching with Gemini
- shared: BaseRepository, common utilities
๐ Complete Guide: resources/domain-driven-design.md
๐ Service Layer
Service Pattern:
- Business logic orchestration
- Domain rule enforcement
- Calls repositories for data
- Returns DTOs, not models directly
- Transaction management
- Uses asyncio.gather for parallel queries
Service Structure:
class UserService:
def __init__(self, session: AsyncSession):
self.session = session
self._user_repo = UserRepository(session)
self._profile_repo = UserProfileRepository(session)
self._data_loader = UserDataLoader(session)
async def get_user_detail(self, user_id: str) -> UserDetailResponse:
user_with_relations = await self._data_loader.load_user_with_relations(
user_id,
load_profile=True,
load_photos=True,
)
if not user_with_relations:
raise NotFoundError(f"User {user_id} not found")
return self._to_detail_response(user_with_relations)
๐ Complete Guide: resources/service-layer.md
๐พ Repository Pattern
Repository Pattern:
- Encapsulates data access
- Extends BaseRepository for CRUD
- Domain-specific queries
- Returns domain models
- All queries are async
- Soft delete support
Repository Structure:
from backend.domain.shared.base_repository import BaseRepository
class UserRepository(BaseRepository[User]):
def __init__(self, session: AsyncSession):
super().__init__(session, User)
async def find_by_phone(self, phone: str) -> Optional[User]:
stmt = select(User).where(
User.phone == phone,
User.deleted_at.is_(None),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
UserDataLoader Pattern (N+1 Prevention):
@dataclass
class UserWithRelations:
user: User
profile: Optional[UserProfile] = None
lifestyle: Optional[UserLifestyle] = None
photos: List[UserPhoto] = field(default_factory=list)
class UserDataLoader:
async def load_user_with_relations(
self,
user_id: str,
load_profile: bool = False,
load_photos: bool = False,
) -> Optional[UserWithRelations]:
queries = [self._load_user(user_id)]
if load_profile:
queries.append(self._load_profile(user_id))
if load_photos:
queries.append(self._load_photos(user_id))
results = await asyncio.gather(*queries)
๐ Complete Guide: resources/repository-pattern.md
๐ DTOs & Validation
Pydantic DTOs:
- Request/Response data transfer objects
- Validation with Pydantic
- Separate from domain models
- Located in
backend/dtos/
- Use field_validator for enum validation
DTO Pattern:
from pydantic import BaseModel, Field, field_validator
from backend.domain.user.enums import GenderEnum, UserStatusEnum
class AdminBasicInfoUpdateRequest(BaseModel):
"""Request DTO for updating basic user info (admin only)."""
name: Optional[str] = Field(None, description="User name")
status: Optional[str] = Field(None, description="User status")
is_admin: Optional[bool] = Field(None, description="Admin flag")
birth_year: Optional[int] = Field(None, ge=1940, le=2010, description="Birth year")
model_config = {"extra": "forbid"}
@field_validator("status")
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
if v is not None:
valid_values = [e.value for e in UserStatusEnum]
if v not in valid_values:
raise ValueError(f"Invalid status: {v}. Valid: {valid_values}")
return v
๐ Complete Guide: resources/dtos-validation.md
โก Async/Await Patterns
Async Best Practices:
- Use async/await throughout
- Async database sessions
- Proper session cleanup
- Avoid blocking operations
- Use asyncio.gather for parallel queries
Async Patterns:
async def get_dashboard_data(self) -> dict:
total, monthly, weekly, today = await asyncio.gather(
self._get_total_count(),
self._get_monthly_count(),
self._get_weekly_count(),
self._get_today_count(),
)
return {
"total_members": total,
"monthly_members": monthly,
"weekly_members": weekly,
"today_members": today,
}
๐ Complete Guide: resources/async-patterns.md
๐จ Error Handling
Error Handling Strategy:
- Custom exception classes in
backend/error/
- HTTP exception mapping via ErrorHandlerMiddleware
- Middleware for error handling
- Consistent error responses
Error Pattern:
class AppException(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(self.message)
class NotFoundError(AppException):
pass
class ForbiddenError(AppException):
pass
class UnauthorizedError(AppException):
pass
if not user:
raise NotFoundError(f"User {user_id} not found")
๐ Complete Guide: resources/error-handling.md
๐ Complete Examples
Full working examples:
- Complete domain (model + repository + service + router)
- CRUD operations with async
- Complex queries with SQLModel
- Firebase/Kakao authentication patterns
- S3 presigned URL generation
- Pagination and filtering
- N+1 prevention with UserDataLoader
๐ Complete Guide: resources/complete-examples.md
Navigation Guide
Core Principles
- Layered Architecture: Router โ Service โ Repository (never skip layers)
- Domain-Driven Design: Organize by domain, not by type
- Async Everything: Use async/await throughout the stack
- Repository Pattern: All data access through repositories
- Service Layer: Business logic in services, not routers or repositories
- DTOs for API: Use Pydantic DTOs for request/response
- Type Hints: Explicit types on all functions and parameters
- Error Handling: Custom exceptions, middleware for HTTP mapping
- Read/Write Split: Separate sessions for read and write operations
- Dependency Injection: Use FastAPI's Depends() for sessions
- ULID IDs: Use ULID with entity prefixes (usr_, mw_, mh_, etc.)
- Soft Delete: Use deleted_at timestamp instead of hard deletes
- N+1 Prevention: Use asyncio.gather and DataLoader patterns
Quick Reference: New Domain Template
from sqlmodel import SQLModel, Field, Column, DateTime, Text
from datetime import datetime, timezone
from typing import Optional
from ulid import ULID
def generate_myfeature_id() -> str:
return f"mf_{ULID()}"
class MyFeature(SQLModel, table=True):
__tablename__ = "my_feature"
id: str = Field(
default_factory=generate_myfeature_id,
primary_key=True,
max_length=30,
)
name: str = Field(sa_column=Column(Text, nullable=False))
created_at: datetime = Field(
sa_column=Column(DateTime(timezone=True), nullable=False),
default_factory=lambda: datetime.now(tz=timezone.utc),
)
deleted_at: Optional[datetime] = Field(
sa_column=Column(DateTime(timezone=True), nullable=True),
default=None,
)
from backend.domain.shared.base_repository import BaseRepository
from sqlmodel.ext.asyncio.session import AsyncSession
class MyFeatureRepository(BaseRepository[MyFeature]):
def __init__(self, session: AsyncSession):
super().__init__(session, MyFeature)
async def find_by_name(self, name: str) -> Optional[MyFeature]:
stmt = select(MyFeature).where(
MyFeature.name == name,
MyFeature.deleted_at.is_(None),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
from sqlmodel.ext.asyncio.session import AsyncSession
from backend.error import NotFoundError
class MyFeatureService:
def __init__(self, session: AsyncSession):
self.session = session
self._repository = MyFeatureRepository(session)
async def get_feature(self, id: str) -> MyFeatureResponse:
feature = await self._repository.get_by_id(id)
if not feature:
raise NotFoundError(f"Feature {id} not found")
return MyFeatureResponse.model_validate(feature)
from pydantic import BaseModel, Field
from datetime import datetime
class MyFeatureResponse(BaseModel):
id: str
name: str
created_at: datetime
class MyFeatureCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel.ext.asyncio.session import AsyncSession
from backend.db.orm import get_read_session_dependency, get_write_session_dependency
router = APIRouter(prefix="/myfeature", tags=["myfeature"])
@router.get("/{id}")
async def get_feature(
id: str,
session: AsyncSession = Depends(get_read_session_dependency),
) -> MyFeatureResponse:
service = MyFeatureService(session)
return await service.get_feature(id)
@router.post("", status_code=201)
async def create_feature(
request: MyFeatureCreateRequest,
session: AsyncSession = Depends(get_write_session_dependency),
) -> MyFeatureResponse:
service = MyFeatureService(session)
return await service.create_feature(request)
Related Skills
- nextjs-frontend-guidelines: Frontend patterns that consume this API
- error-tracking: Error tracking with Sentry (backend integration)
- pytest-backend-testing: Testing patterns for FastAPI backends
Skill Status: Modular structure with progressive loading for optimal context management