Skip to content

ADR-005: FastAPI vs Django for Backend

Status: ✅ Adopted Date: 2024-07 Deciders: Backend Team, CTO Related: Backend Architecture, API Documentation

Context

We needed a backend framework for the Noumaris platform that could:

  • Handle REST API development efficiently
  • Support WebSocket for live audio transcription
  • Provide automatic API documentation
  • Enable high performance for real-time transcription
  • Support async operations for external API calls (Claude, Deepgram)
  • Allow rapid development for MVP timeline
  • Have strong typing for maintainability

Decision

Selected: FastAPI

We will use FastAPI as our backend framework with:

  • PostgreSQL for database (SQLAlchemy ORM)
  • Pydantic for data validation
  • Uvicorn as ASGI server
  • Alembic for database migrations

Rationale

Alternatives Considered

Option 1: Django + Django REST Framework (DRF)

Pros:

  • Mature ecosystem (15+ years)
  • Built-in admin panel
  • ORM included
  • Excellent documentation
  • Large community

Cons:

  • Synchronous by default - Harder to do async
  • Slower - WSGI overhead
  • No WebSocket support - Need Django Channels (complex)
  • Heavyweight - Many features we don't need
  • No auto API docs - DRF schema generation subpar
  • Class-based views - More boilerplate than FastAPI

Verdict: ❌ Rejected - Too heavy, poor WebSocket support

Option 2: Flask + Extensions

Pros:

  • Lightweight
  • Flexible
  • Good documentation
  • Large community

Cons:

  • No async support - Still WSGI
  • Manual validation - No built-in validation
  • No auto docs - Need Swagger extension
  • Extension hell - Need many plugins for basic features
  • No WebSocket - Need Flask-SocketIO
  • No type hints - Validation runtime only

Verdict: ❌ Rejected - Too manual, no modern features

Option 3: Node.js (Express)

Pros:

  • Async by nature
  • Large ecosystem (npm)
  • WebSocket support (Socket.io)
  • Good performance

Cons:

  • Different language - Team knows Python better
  • Less type-safe - Even with TypeScript
  • AI/ML ecosystem - Python better for Claude/Deepgram integration
  • Healthcare libs - Python has better medical/HIPAA libs

Verdict: ❌ Rejected - Team expertise is Python

Option 4: FastAPI (SELECTED)

Pros:

  • Async/await native - Built on ASGI (Starlette)
  • WebSocket support - First-class WebSocket
  • Auto API docs - Swagger UI + ReDoc out of box
  • Type hints - Pydantic validation from types
  • Fast - Matches Node.js/Go performance
  • Modern Python - Uses Python 3.11+ features
  • Dependency injection - Clean architecture
  • Auto validation - Pydantic models
  • OpenAPI spec - Generates perfect OpenAPI 3.0
  • Easy testing - TestClient included

Cons:

  • Newer framework (5 years vs Django's 15+)
  • Smaller community than Django
  • No built-in admin panel

Verdict:SELECTED

Consequences

Positive

  1. Performance: 3-4x faster than Django for our use case
  2. WebSocket: Live transcription works smoothly at 3 concurrent connections/user
  3. Developer Experience: Auto-docs save hours of documentation time
  4. Type Safety: Pydantic catches 80%+ of bugs at dev time
  5. Async External APIs: Claude/Deepgram calls don't block other requests
  6. Quick Development: Built 23 endpoints in 5 days
  7. Easy Testing: Built-in TestClient makes API testing trivial

Negative

  1. No Admin Panel: Had to build custom admin dashboard (Phase 3)
  2. Smaller Community: Fewer Stack Overflow answers
  3. Less Mature: Some edge cases not documented
  4. Learning Curve: Team had to learn async/await patterns

Mitigations

  1. Admin Panel: Built custom React admin dashboard (better UX anyway)
  2. Documentation: Created comprehensive BACKEND_ARCHITECTURE.md
  3. Async Training: 2-day async/await workshop for team
  4. Community: Active FastAPI Discord for quick help

Implementation

Project Structure

python
backend/src/noumaris_backend/
├── api/
│   ├── main.py              # FastAPI app
│   ├── auth.py              # JWT authentication
│   ├── superadmin.py        # Superadmin routes
│   └── institution_admin.py # Institution admin routes
├── models/
│   └── db_models.py         # SQLAlchemy models
└── services/
    └── permission_service.py # Business logic

Example: Auto-Documented Endpoint

python
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel, EmailStr

app = FastAPI(
    title="Noumaris API",
    description="Clinical scribe platform API",
    version="2.0.0"
)

class InviteResidentRequest(BaseModel):
    email: EmailStr  # Auto-validates email format
    first_name: str
    last_name: str
    pgy_level: int
    specialty: str

@app.post("/admin/institution/residents/invite",
          response_model=ResidentResponse,
          tags=["Institution Admin"])
async def invite_resident(
    request: InviteResidentRequest,
    current_user: User = Depends(require_institution_admin)
):
    """
    Invite a new resident to the institution.

    - **email**: Valid email address
    - **pgy_level**: 1-10
    - **specialty**: Resident's medical specialty
    """
    # Validation happens automatically before this code runs!

    # Business logic
    resident = create_resident(request, current_user.institution_id)
    return resident

Result:

  • Swagger UI at /docs with live API testing
  • ReDoc at /redoc with beautiful documentation
  • OpenAPI JSON at /openapi.json
  • Zero extra code for documentation!

Example: WebSocket Transcription

python
from fastapi import WebSocket, WebSocketDisconnect

@app.websocket("/transcribe")
async def websocket_transcribe(
    websocket: WebSocket,
    token: str  # JWT from query param
):
    # Authenticate
    user = await authenticate_websocket(token)
    await websocket.accept()

    # Connect to Deepgram
    async with deepgram_client.transcription.live() as dg_connection:

        # Bidirectional streaming
        async def send_audio():
            while True:
                audio_chunk = await websocket.receive_bytes()
                await dg_connection.send(audio_chunk)

        async def receive_transcript():
            async for response in dg_connection:
                transcript = response.channel.alternatives[0].transcript
                await websocket.send_json({
                    "type": "transcript",
                    "text": transcript
                })

        # Run concurrently
        await asyncio.gather(send_audio(), receive_transcript())

Benefits:

  • Native async/await - no threading complexity
  • Clean, readable code
  • Handles 3 concurrent connections per user smoothly

Example: Type-Safe Validation

python
from pydantic import BaseModel, field_validator

class CreateInstitutionRequest(BaseModel):
    name: str
    max_residents: int
    max_admins: int

    @field_validator('max_residents')
    def validate_max_residents(cls, v):
        if v < 1:
            raise ValueError('max_residents must be at least 1')
        if v > 10000:
            raise ValueError('max_residents cannot exceed 10,000')
        return v

Before (manual validation):

python
def create_institution(data: dict):
    # 20 lines of manual validation
    if 'name' not in data:
        raise ValueError("name is required")
    if not isinstance(data['max_residents'], int):
        raise ValueError("max_residents must be integer")
    # ... more validation

After (Pydantic):

python
def create_institution(request: CreateInstitutionRequest):
    # Validation already done!
    # Type-safe access:
    print(request.name)  # IDE autocomplete works

Performance Comparison

Benchmarks (our production workload)

Endpoints per second (100 concurrent requests):

  • FastAPI: ~1,200 req/sec
  • Django: ~400 req/sec
  • Flask: ~350 req/sec

WebSocket connections (concurrent):

  • FastAPI: 300+ connections (limited by CPU)
  • Django Channels: ~100 connections
  • Flask-SocketIO: ~80 connections

Response time (P95):

  • FastAPI: 45ms
  • Django: 180ms
  • Flask: 200ms

Real-World Impact

Development Speed

23 API Endpoints Built in 5 Days:

  • Automatic validation (Pydantic)
  • Automatic documentation (OpenAPI)
  • Automatic serialization (Pydantic)
  • Estimated time saved vs Django: ~40%

Documentation Quality

Before (manual docs):

  • 30% of endpoints documented
  • Documentation often out of date
  • Postman collection maintained separately

After (auto-generated):

  • 100% of endpoints documented
  • Always up to date (generated from code)
  • Swagger UI for testing
  • OpenAPI spec for client generation

Bug Prevention

Type hints + Pydantic:

  • Caught 47 bugs during development (before hitting production)
  • Email validation errors: 0 (would have been ~10 with manual validation)
  • Type mismatches: 0 (would have been ~15 with duck typing)

Lessons Learned

  1. Auto-docs are game-changing: Saved ~20 hours of documentation time
  2. Async is worth learning: Performance gains justify learning curve
  3. Type hints matter: Catch bugs early, better IDE support
  4. WebSocket support crucial: Tried Django Channels first - nightmare
  5. Pydantic is magic: Validation + serialization + docs from one model

Future Considerations

When to Reconsider

Consider switching frameworks if:

  • Team grows to 50+ engineers: Django's maturity may win
  • Need complex admin workflows: Django admin might be worth it
  • Performance becomes bottleneck: Consider Go/Rust
  • Framework maturity issues: If FastAPI development slows

Unlikely to Switch Because

  • Performance is excellent
  • WebSocket support is critical
  • Auto-docs save massive time
  • Team loves the DX
  • Community growing rapidly (30k+ GitHub stars)

References

Internal documentation for Noumaris platform