API Development Rules
Comprehensive best practices and coding standards for building production-grade APIs with Fast
Core Principles
Single Responsibility
Each module should have one reason to change. Controllers handle HTTP, Services handle business logic, Repositories handle data access.
Dependency Injection
Always use constructor injection for dependencies. Never instantiate services or repositories directly inside methods.
# Good
class UserController:
def __init__(self, user_service: UserService):
self.user_service = user_service
# Bad
class UserController:
def get_user(self, id: str):
user_service = UserService() # Don't do this
return user_service.get(id)
Explicit over Implicit
Be explicit about types, return values, and exceptions. Use type hints everywhere.
Controllers Required
Keep Controllers Thin
Controllers should only handle HTTP concerns: routing, request validation, and response formatting.
# Good
@router.get("/users/{id}")
async def get_user(id: UUID, controller: UserController = Depends()) -> UserResponse:
return await controller.get_user(id)
class UserController:
async def get_user(self, id: UUID) -> UserResponse:
user = await self.user_service.get_by_id(id)
return UserResponse.from_entity(user)
No Business Logic in Controllers
Never put business logic, database queries, or complex calculations in controllers.
# Bad - Don't do this
@router.post("/orders")
async def create_order(request: OrderRequest):
# Business logic in controller!
if request.quantity > 100:
raise HTTPException(400, "Too many items")
# Database access in controller!
order = await db.query(Order).insert(request)
# External API call in controller!
await payment_api.charge(order.total)
return order
Use Dependency Injection
Inject services via FastAPI's Depends() or constructor injection.
# Good
@router.get("/users")
async def list_users(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
controller: UserController = Depends(get_user_controller)
) -> PaginatedResponse[UserResponse]:
return await controller.list_users(page=page, limit=limit)
Return Typed Responses
Always specify return types for route handlers.
@router.post("/users", response_model=UserResponse, status_code=201)
async def create_user(request: CreateUserRequest) -> UserResponse:
...
Services Required
Services Contain Business Logic
All business rules, validations, and orchestration belongs in services.
class OrderService:
def __init__(
self,
order_repo: OrderRepository,
inventory_repo: InventoryRepository,
payment_gateway: PaymentGateway
):
self.order_repo = order_repo
self.inventory_repo = inventory_repo
self.payment_gateway = payment_gateway
async def create_order(self, dto: CreateOrderDTO) -> Order:
# Business logic
if not await self.inventory_repo.check_availability(dto.items):
raise InsufficientInventoryError()
# Calculate totals with business rules
total = self._calculate_total(dto.items, dto.discount_code)
# Create order
order = Order(items=dto.items, total=total)
await self.order_repo.save(order)
return order
No HTTP Concerns in Services
Services should never import from fastapi or deal with HTTP specifics.
# Bad
from fastapi import HTTPException # Don't import in services!
class UserService:
async def get_user(self, id: UUID):
user = await self.repo.get(id)
if not user:
raise HTTPException(404, "Not found") # Wrong!
return user
# Good
class UserService:
async def get_user(self, id: UUID) -> User:
user = await self.repo.get(id)
if not user:
raise UserNotFoundError(id) # Domain exception
return user
Use Transactions
Wrap multi-step operations in transactions.
async def transfer_funds(self, from_id: UUID, to_id: UUID, amount: Decimal):
async with self.unit_of_work:
from_account = await self.account_repo.get(from_id)
to_account = await self.account_repo.get(to_id)
from_account.debit(amount)
to_account.credit(amount)
await self.account_repo.update(from_account)
await self.account_repo.update(to_account)
Cache Expensive Operations
Use @smart_cache for expensive or frequently accessed data.
@smart_cache.cached(ttl=300, invalidate_on=["user:updated"])
async def get_user_dashboard(self, user_id: UUID) -> Dashboard:
# Expensive aggregation
stats = await self.calculate_user_stats(user_id)
return Dashboard(stats=stats)
Repositories Required
Abstract Database Details
Repositories should hide database implementation details. Return domain entities, not ORM models.
class UserRepository:
def __init__(self, db_session: AsyncSession):
self._db = db_session
async def get_by_id(self, id: UUID) -> User | None:
result = await self._db.execute(
select(UserModel).where(UserModel.id == id)
)
orm_user = result.scalar_one_or_none()
return User.from_orm(orm_user) if orm_user else None
No Business Logic
Repositories only handle data access. No business rules, calculations, or validations.
Use Specific Methods
Create specific query methods instead of generic finders.
# Good
async def get_active_users_by_role(self, role: Role) -> list[User]:
...
async def search_by_email_domain(self, domain: str) -> list[User]:
...
# Bad
async def find(self, **filters) -> list[User]: # Too generic
...
Implement Pagination
Always paginate list queries.
async def list_users(
self,
page: int = 1,
limit: int = 20,
filters: UserFilters | None = None
) -> PaginatedResult[User]:
query = select(UserModel)
if filters:
query = self._apply_filters(query, filters)
return await self._paginate(query, page, limit)
DTOs & Schemas Required
Separate Input/Output DTOs
Never reuse the same DTO for requests and responses.
class CreateUserRequest(BaseModel):
email: EmailStr
password: SecretStr
name: str
class UserResponse(BaseModel):
id: UUID
email: EmailStr
name: str
created_at: datetime
# No password here!
Use Strong Types
Use Pydantic's constrained types for validation.
from pydantic import EmailStr, Field, SecretStr
class CreateUserRequest(BaseModel):
email: EmailStr # Validates email format
password: SecretStr = Field(min_length=8)
age: int = Field(ge=0, le=150)
tags: list[str] = Field(max_length=10)
Never Expose Internal IDs
Use UUIDs for external APIs. Never expose auto-increment database IDs.
Use Config.extra = 'forbid'
Prevent unexpected fields in requests.
class CreateOrderRequest(BaseModel):
items: list[OrderItemDTO]
shipping_address: AddressDTO
class Config:
extra = 'forbid' # Raises error if unexpected fields provided
Models/Entities Required
Domain Entities are Rich
Entities should encapsulate business rules and behavior.
class Order:
def __init__(self, items: list[OrderItem]):
self.items = items
self.status = OrderStatus.PENDING
self._validate_items()
def _validate_items(self):
if not self.items:
raise EmptyOrderError()
if len(self.items) > 100:
raise TooManyItemsError(max=100)
def calculate_total(self) -> Decimal:
return sum(item.price * item.quantity for item in self.items)
def confirm(self):
if self.status != OrderStatus.PENDING:
raise InvalidOrderStateError()
self.status = OrderStatus.CONFIRMED
No Anemic Models
Don't create models that are just data bags with getters/setters.
Use Value Objects
Encapsulate complex types in value objects.
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: Currency
def add(self, other: 'Money') -> 'Money':
if self.currency != other.currency:
raise CurrencyMismatchError()
return Money(self.amount + other.amount, self.currency)
Error Handling Required
Use Domain Exceptions
Create specific exception types for different error scenarios.
class DomainError(Exception):
pass
class UserNotFoundError(DomainError):
def __init__(self, user_id: UUID):
self.user_id = user_id
super().__init__(f"User {user_id} not found")
class InsufficientFundsError(DomainError):
def __init__(self, balance: Decimal, required: Decimal):
self.balance = balance
self.required = required
super().__init__(f"Balance {balance} < required {required}")
Global Exception Handlers
Map domain exceptions to HTTP responses in one place.
@app.exception_handler(UserNotFoundError)
async def handle_user_not_found(request: Request, exc: UserNotFoundError):
return JSONResponse(
status_code=404,
content={"error": "USER_NOT_FOUND", "user_id": str(exc.user_id)}
)
Include Error Codes
Return machine-readable error codes for client handling.
{
"error": "INSUFFICIENT_INVENTORY",
"code": "INV_001",
"message": "Item XYZ is out of stock",
"details": {
"item_id": "XYZ",
"requested": 10,
"available": 3
}
}
Testing Recommended
Test Services in Isolation
Mock repositories when testing services.
async def test_order_service_creates_order():
# Arrange
mock_repo = Mock(OrderRepository)
mock_repo.save = AsyncMock(return_value=None)
service = OrderService(mock_repo, ...)
# Act
order = await service.create_order(valid_dto)
# Assert
assert order.status == OrderStatus.PENDING
mock_repo.save.assert_called_once()
Integration Tests for Repositories
Test repositories against real database with testcontainers.
@pytest.mark.asyncio
async def test_user_repository_get_by_id(db_session):
# Setup
user = UserFactory.create()
await db_session.flush()
# Test
repo = UserRepository(db_session)
result = await repo.get_by_id(user.id)
assert result is not None
assert result.email == user.email
E2E Tests for Critical Paths
Test complete request flows with test client.
def test_create_user_endpoint(client: TestClient):
response = client.post("/users", json={
"email": "test@example.com",
"password": "secure123",
"name": "Test User"
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "test@example.com"
assert "password" not in data
Naming Conventions Required
Controllers
- Singular noun + "Controller"
- UserController, OrderController
Services
- Singular noun + "Service"
- UserService, PaymentService
Repositories
- Singular noun + "Repository"
- UserRepository, OrderRepository
DTOs
- Action + Entity + "Request/Response"
- CreateUserRequest, UserResponse
Method Naming
- Controllers: HTTP methods (get, post, put, delete) or action names (create, update, remove)
- Services: Business actions (create_order, process_payment, calculate_total)
- Repositories: Data operations (get_by_id, find_by_email, save, delete)