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)

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)}
    )

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)