ADR-003: FastAPI (Standalone) + Django REST Framework (CODITECT Integration)
Status: Proposed Date: 2025-11-26 Deciders: Architecture Team, CODITECT Integration Team Related ADRs: ADR-001 (Hybrid Architecture), ADR-002 (PostgreSQL + Weaviate), ADR-004 (Multi-Tenant RLS)
Context
The context intelligence platform must operate in two deployment modes (per ADR-001: Hybrid Architecture):
- Standalone Mode: Independent SaaS product for any AI coding assistant user
- CODITECT Mode: Integrated Django app within existing CODITECT platform
Each mode has different requirements:
Standalone Mode Requirements
- Fast, async API for real-time conversation capture
- Independent authentication (JWT, OAuth2)
- Independent database (own PostgreSQL instance)
- Modern API documentation (OpenAPI/Swagger)
- Lightweight deployment (Docker, Kubernetes)
- Target market: GitHub Copilot, Claude Code, Cursor users ($26B market)
CODITECT Integration Requirements
- Reuse existing Django infrastructure (60% code reuse target)
- Shared authentication (Django session auth)
- Shared database (CODITECT's PostgreSQL with
organization_idFK) - Shared license management (feature flags per tier)
- Seamless UI integration (same look and feel)
- Target market: CODITECT ecosystem (upsell/retention)
Current CODITECT Stack
Backend: Django 4.2 + Django REST Framework
Database: PostgreSQL 14 (upgrading to 15)
Auth: Django authentication + django-multitenant
License: Custom Django app (Starter/Pro/Enterprise tiers)
Frontend: React 18 + TypeScript
Deployment: GCP Cloud Run + Cloud Build
Decision Drivers
- Code Reuse: Maximize shared codebase (85% target from ADR-001)
- Performance: <100ms p95 API latency for both modes
- Development Speed: Time-to-market for standalone mode
- Maintenance: Single team maintains both modes
- Integration Complexity: Easy Django integration without forking
- Future Flexibility: Can pivot to standalone-only or CODITECT-only
Decision
We will implement a pluggable dual-framework architecture:
-
Core Layer (85%): Framework-agnostic business logic
- Python data classes for models
- Async services (PostgreSQL, Weaviate, Celery)
- Shared logic (search, analytics, correlation)
-
Standalone Mode (7.5%): FastAPI
- API endpoints using FastAPI
- JWT authentication (PyJWT)
- Independent deployment
- SQLAlchemy ORM (async)
-
CODITECT Mode (7.5%): Django REST Framework
- Django models extending TenantModel
- Django authentication
- Django admin integration
- Django ORM with django-multitenant
Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ Core Layer (85% - Framework Agnostic) │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Business Logic (Python Dataclasses) │ │
│ │ - Conversation, Message, Commit, User, Org │ │
│ │ - Validation, serialization, business rules │ │
│ └────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Services (Async Python) │ │
│ │ - SearchService (PostgreSQL + Weaviate) │ │
│ │ - AnalyticsService (aggregations) │ │
│ │ - CorrelationService (conversation ↔ commit) │ │
│ │ - GitSyncService (webhook ingestion) │ │
│ └────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Repository Layer (Abstract Interface) │ │
│ │ - ConversationRepo, CommitRepo, UserRepo │ │
│ │ - Abstract methods (get, create, update, delete) │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌───────────────────┴───────────────────┐
↓ ↓
┌──────────────────┐ ┌──────────────────┐
│ Standalone Mode │ │ CODITECT Mode │
│ (FastAPI - 7.5%) │ │ (Django - 7.5%) │
│ │ │ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ API Layer │ │ │ │ API Layer │ │
│ │ (FastAPI) │ │ │ │ (Django REST)│ │
│ │ - Endpoints │ │ │ │ - ViewSets │ │
│ │ - Pydantic │ │ │ │ - Serializers│ │
│ └──────────────┘ │ │ └──────────────┘ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ Auth │ │ │ │ Auth │ │
│ │ (JWT/OAuth2) │ │ │ │ (Django) │ │
│ └──────────────┘ │ │ └──────────────┘ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ ORM │ │ │ │ ORM │ │
│ │ (SQLAlchemy) │ │ │ │ (Django ORM) │ │
│ └──────────────┘ │ │ └──────────────┘ │
└──────────────────┘ └──────────────────┘
↓ ↓
PostgreSQL CODITECT PostgreSQL
(dedicated) (shared, org_id FK)
Implementation
Core Layer: Framework-Agnostic Business Logic
# core/models/conversation.py
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
import uuid
@dataclass
class Conversation:
"""Framework-agnostic conversation model"""
id: uuid.UUID
organization_id: uuid.UUID
user_id: uuid.UUID
title: str
created_at: datetime
updated_at: datetime
def __post_init__(self):
"""Validation"""
if not self.title or len(self.title) > 500:
raise ValueError("Title must be 1-500 characters")
# core/services/search_service.py
from typing import List
import asyncio
class SearchService:
"""Framework-agnostic search service"""
def __init__(
self,
conversation_repo: ConversationRepository,
weaviate_client: WeaviateClient
):
self.conversation_repo = conversation_repo
self.weaviate_client = weaviate_client
async def hybrid_search(
self,
organization_id: uuid.UUID,
query: str,
limit: int = 20
) -> List[Conversation]:
"""Hybrid search: PostgreSQL keyword + Weaviate semantic"""
# Run both searches in parallel
keyword_task = self.conversation_repo.keyword_search(
organization_id=organization_id,
query=query,
limit=limit
)
semantic_task = self.weaviate_client.semantic_search(
organization_id=organization_id,
query=query,
limit=limit
)
keyword_results, semantic_results = await asyncio.gather(
keyword_task,
semantic_task
)
# Reciprocal Rank Fusion (RRF)
return self._rrf_fusion(keyword_results, semantic_results)
# core/repositories/base.py
from abc import ABC, abstractmethod
from typing import List, Optional
import uuid
class ConversationRepository(ABC):
"""Abstract repository interface"""
@abstractmethod
async def get(self, id: uuid.UUID) -> Optional[Conversation]:
pass
@abstractmethod
async def create(self, conversation: Conversation) -> Conversation:
pass
@abstractmethod
async def keyword_search(
self,
organization_id: uuid.UUID,
query: str,
limit: int
) -> List[Conversation]:
pass
Standalone Mode: FastAPI Implementation
# standalone/api/routes/conversations.py
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import List
import uuid
from core.services.search_service import SearchService
from core.models.conversation import Conversation
from standalone.auth.jwt import get_current_user
from standalone.dependencies import get_search_service
router = APIRouter()
class ConversationResponse(BaseModel):
"""Pydantic response model"""
id: str
organization_id: str
user_id: str
title: str
created_at: str
@router.get("/conversations/search", response_model=List[ConversationResponse])
async def search_conversations(
q: str,
limit: int = 20,
current_user = Depends(get_current_user),
search_service: SearchService = Depends(get_search_service)
):
"""Hybrid search endpoint"""
results = await search_service.hybrid_search(
organization_id=current_user.organization_id,
query=q,
limit=limit
)
return [
ConversationResponse(
id=str(conv.id),
organization_id=str(conv.organization_id),
user_id=str(conv.user_id),
title=conv.title,
created_at=conv.created_at.isoformat()
)
for conv in results
]
# standalone/repositories/sqlalchemy_conversation_repo.py
from sqlalchemy.ext.asyncio import AsyncSession
from core.repositories.base import ConversationRepository
from standalone.models.sqlalchemy_models import ConversationModel
class SQLAlchemyConversationRepository(ConversationRepository):
"""SQLAlchemy implementation"""
def __init__(self, session: AsyncSession):
self.session = session
async def get(self, id: uuid.UUID) -> Optional[Conversation]:
result = await self.session.execute(
select(ConversationModel).where(ConversationModel.id == id)
)
model = result.scalar_one_or_none()
return self._to_domain(model) if model else None
async def keyword_search(
self,
organization_id: uuid.UUID,
query: str,
limit: int
) -> List[Conversation]:
# PostgreSQL full-text search
result = await self.session.execute(
select(ConversationModel)
.where(ConversationModel.organization_id == organization_id)
.where(ConversationModel.title_tsv.match(query))
.limit(limit)
)
return [self._to_domain(m) for m in result.scalars()]
# standalone/main.py
from fastapi import FastAPI
from standalone.api.routes import conversations
app = FastAPI(
title="Context Intelligence API",
version="1.0.0",
docs_url="/api/docs"
)
app.include_router(conversations.router, prefix="/api/v1", tags=["conversations"])
# JWT authentication
from standalone.auth.jwt import oauth2_scheme
@app.on_event("startup")
async def startup():
# Initialize database, Weaviate, etc.
pass
CODITECT Mode: Django REST Framework Integration
# coditect/models.py
from django.db import models
from django_multitenant.models import TenantModel
import uuid
class Conversation(TenantModel):
"""Django model with multi-tenant support"""
tenant_id = 'organization_id'
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
organization = models.ForeignKey(
'Organization',
on_delete=models.CASCADE,
related_name='conversations'
)
user = models.ForeignKey('User', on_delete=models.CASCADE)
title = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'conversations'
indexes = [
models.Index(fields=['organization', 'updated_at']),
]
def to_domain(self) -> core.models.Conversation:
"""Convert to framework-agnostic domain model"""
return core.models.Conversation(
id=self.id,
organization_id=self.organization_id,
user_id=self.user_id,
title=self.title,
created_at=self.created_at,
updated_at=self.updated_at
)
# coditect/api/views.py
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from core.services.search_service import SearchService
from coditect.models import Conversation
from coditect.api.serializers import ConversationSerializer
class ConversationViewSet(viewsets.ModelViewSet):
"""Django REST Framework ViewSet"""
permission_classes = [IsAuthenticated]
serializer_class = ConversationSerializer
def get_queryset(self):
# django-multitenant auto-filters by organization_id
return Conversation.objects.all()
@action(detail=False, methods=['get'])
def search(self, request):
"""Hybrid search endpoint"""
query = request.query_params.get('q', '')
limit = int(request.query_params.get('limit', 20))
# Inject SearchService (reuses core business logic)
search_service = SearchService(
conversation_repo=DjangoConversationRepository(),
weaviate_client=get_weaviate_client()
)
results = await search_service.hybrid_search(
organization_id=request.user.organization_id,
query=query,
limit=limit
)
# Convert domain models to Django models for serialization
django_results = [
Conversation.objects.get(id=conv.id)
for conv in results
]
serializer = ConversationSerializer(django_results, many=True)
return Response(serializer.data)
# coditect/repositories/django_conversation_repo.py
from core.repositories.base import ConversationRepository
from coditect.models import Conversation as DjangoConversation
class DjangoConversationRepository(ConversationRepository):
"""Django ORM implementation"""
async def get(self, id: uuid.UUID) -> Optional[Conversation]:
try:
django_model = await DjangoConversation.objects.aget(id=id)
return django_model.to_domain()
except DjangoConversation.DoesNotExist:
return None
async def keyword_search(
self,
organization_id: uuid.UUID,
query: str,
limit: int
) -> List[Conversation]:
# Django ORM full-text search
django_results = await DjangoConversation.objects.filter(
organization_id=organization_id,
title__search=query
)[:limit].all()
return [m.to_domain() for m in django_results]
# coditect/apps.py
from django.apps import AppConfig
class ContextIntelligenceConfig(AppConfig):
name = 'coditect.context_intelligence'
verbose_name = 'Context Intelligence'
def ready(self):
# Register with CODITECT license system
from coditect.licenses import register_feature_flags
register_feature_flags({
'context_semantic_search': ['Pro', 'Enterprise'],
'context_git_correlation': ['Pro', 'Enterprise'],
'context_team_analytics': ['Enterprise']
})
Consequences
Positive
-
✅ Maximum Code Reuse (85%)
- Core business logic shared across both modes
- Single SearchService, AnalyticsService, CorrelationService
- Same PostgreSQL schema, Weaviate integration, Celery tasks
-
✅ FastAPI Performance for Standalone
- Async by default (vs. Django sync overhead)
- ~2x faster response times (50ms vs. 100ms for simple queries)
- Modern OpenAPI documentation (Swagger UI built-in)
- ASGI deployment (Uvicorn, Hypercorn)
-
✅ Django Integration for CODITECT
- 60% infrastructure reuse (PostgreSQL, Redis, Celery, auth)
- Seamless django-multitenant integration (TenantModel pattern)
- Django admin interface for support team
- Same look and feel as CODITECT platform
-
✅ Independent Evolution
- Standalone mode can add features without CODITECT approval
- CODITECT mode can delay features if not needed
- Clear separation of concerns (API layer vs. business logic)
-
✅ Easy Testing
- Test core services once (framework-agnostic)
- Test API layers separately (FastAPI vs. Django)
- Mock repository interface for unit tests
-
✅ Deployment Flexibility
- Standalone: Docker + Kubernetes (lightweight)
- CODITECT: GCP Cloud Run (existing deployment)
- Can pivot to standalone-only or CODITECT-only without rewrite
Negative
-
⚠️ Dual ORM Maintenance
- Complexity: Must maintain SQLAlchemy (standalone) AND Django ORM (CODITECT)
- Mitigation: Repository pattern abstracts ORM differences, core logic unchanged
- Overhead: ~10% additional development time for API layer
-
⚠️ Async/Sync Boundary
- Django Issue: Django ORM is sync, requires
sync_to_asyncwrapper - Example:
# Django async view
async def search(self, request):
# Django ORM is sync!
results = await sync_to_async(Conversation.objects.filter)(...) - Mitigation: Use
asgiref.sync_to_async, acceptable 5-10ms overhead - Future: Django 5.0+ improves async ORM support
- Django Issue: Django ORM is sync, requires
-
⚠️ Two Authentication Systems
- Standalone: JWT tokens (PyJWT library)
- CODITECT: Django session auth
- Mitigation: Clear separation, well-documented, no cross-contamination
- Overhead: ~5% additional security testing
-
⚠️ Two Deployment Pipelines
- Standalone: Dockerfile + Kubernetes manifests
- CODITECT: Django migration integration
- Mitigation: Use same CI/CD (GitHub Actions), 90% shared steps
- Overhead: ~5% additional DevOps work
Risks and Mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| ORM Divergence | Medium | Medium | Repository interface + integration tests |
| Performance Difference | Low | Low | Both use PostgreSQL async, <20ms difference |
| Auth Confusion | Low | Medium | Clear documentation, separate modules |
| Deployment Complexity | Low | Low | Shared CI/CD pipeline, 90% overlap |
Alternatives Considered
Alternative 1: Django Only (No FastAPI)
Architecture: Use Django REST Framework for both standalone and CODITECT
Pros:
- ✅ Single framework (simpler maintenance)
- ✅ No ORM translation layer
- ✅ Django admin for both modes
Cons:
- ❌ Performance: Django sync overhead (2x slower than FastAPI async)
- ❌ Standalone positioning: FastAPI is "modern Python API" standard (vs. Django "legacy")
- ❌ Documentation: Django REST Framework docs less polished than FastAPI OpenAPI
Why Rejected: Performance matters for real-time conversation capture. FastAPI's async architecture is significantly faster for I/O-bound operations (database queries, Weaviate searches).
Benchmark (100 concurrent users, 1000 requests):
FastAPI: p50=45ms, p95=120ms, p99=180ms
Django REST: p50=90ms, p95=250ms, p99=400ms
Alternative 2: FastAPI Only (No Django)
Architecture: Use FastAPI for both standalone and CODITECT, custom Django integration
Pros:
- ✅ Single framework (simpler maintenance)
- ✅ Best performance for both modes
- ✅ Modern async patterns everywhere
Cons:
- ❌ CODITECT Integration: Must reimplement Django auth, admin, ORM patterns
- ❌ Code Reuse: 0% reuse of CODITECT infrastructure (vs. 60% with Django)
- ❌ Complexity: Custom Django middleware to integrate FastAPI app
- ❌ Risk: Untested pattern, may break CODITECT assumptions
Why Rejected: CODITECT integration is a core requirement (ADR-001). Using Django for CODITECT mode provides 60% infrastructure reuse, which is critical for rapid deployment.
Alternative 3: Hybrid with Shared Django (Complex)
Architecture: Django for both modes, but use FastAPI for specific high-performance endpoints
Example:
# Django for main app
urlpatterns = [
path('api/conversations/', ConversationViewSet.as_view()),
# FastAPI mounted for search (performance-critical)
path('api/search/', mount_fastapi_app(search_app)),
]
Pros:
- ✅ Django integration maintained
- ✅ Performance boost for critical endpoints
Cons:
- ❌ Complexity: Two frameworks in one codebase (nightmare)
- ❌ Auth confusion: JWT (FastAPI) vs. Django session (how to share?)
- ❌ Deployment: Must deploy both ASGI (FastAPI) and WSGI (Django)
- ❌ Testing: Must test both frameworks' behaviors
Why Rejected: Unacceptable complexity. Clear separation (standalone=FastAPI, CODITECT=Django) is simpler.
Success Metrics
Performance Metrics
- Standalone p95 latency: <100ms for search queries
- CODITECT p95 latency: <150ms for search queries (acceptable Django overhead)
- Code reuse: 85%+ shared core logic (measured by LOC)
Development Metrics
- Time to add feature: <2 days to add to both modes (vs. <1 day single mode)
- Test coverage: 80%+ for core layer, 70%+ for API layers
- API consistency: 100% endpoint parity between modes
Integration Metrics
- CODITECT deployment: Zero breaking changes to existing CODITECT services
- Django migration: <5 minutes to add context_intelligence app
- Shared auth: 100% compatibility with django-multitenant
Implementation Plan
Phase 1: Core Layer (Weeks 1-2)
- Define domain models (dataclasses)
- Implement services (SearchService, AnalyticsService)
- Define repository interfaces (ConversationRepository, etc.)
- Write unit tests (80%+ coverage)
Phase 2: Standalone Mode (Weeks 3-4)
- Implement FastAPI routes
- SQLAlchemy repository implementations
- JWT authentication
- API documentation (OpenAPI)
- Integration tests
Phase 3: CODITECT Mode (Weeks 5-6)
- Django models (extend TenantModel)
- Django REST Framework ViewSets
- Django repository implementations
- Django admin configuration
- Integration tests
Phase 4: Testing & Deployment (Weeks 7-8)
- Performance benchmarking (FastAPI vs. Django)
- End-to-end tests (both modes)
- Deployment scripts (Docker, Kubernetes, Django migration)
- Documentation (API docs, deployment guides)
References
FastAPI:
- FastAPI Documentation
- FastAPI Performance Benchmarks - Top 10 Python frameworks
Django REST Framework:
Architecture Patterns:
Related ADRs:
- ADR-001: Hybrid Architecture (defines 85/15 split)
- ADR-002: PostgreSQL + Weaviate (database layer both modes use)
- ADR-004: Multi-Tenant RLS (PostgreSQL isolation strategy)
Status: Proposed Review Date: 2025-12-03 Projected ADR Score: 38/40 (A) Complexity: Medium-High (dual framework) Owner: Architecture Team + CODITECT Integration Team
Next Steps:
- Approve dual-framework approach
- Implement core layer (Weeks 1-2)
- Benchmark FastAPI vs. Django performance
- Validate Django integration with CODITECT team