# Dual API Architecture Changemaker Lite V2 uses a dual API architecture with Express.js for main features and Fastify for the media library microservice. ## Why Dual API? ### Performance Isolation Media operations (video processing, large uploads) are isolated from core platform features: - **Video uploads** don't block campaign email sending - **Media job processing** doesn't affect map rendering - **Large file transfers** have separate connection pools ### Technology Evaluation V2 evaluates two popular Node.js frameworks side-by-side: | Feature | Express.js | Fastify | |---------|-----------|---------| | **Ecosystem** | Massive (15+ years) | Growing (7+ years) | | **Performance** | Good | Excellent (2-3x faster) | | **TypeScript** | Requires @types/* | Native support | | **Middleware** | Industry standard | Plugin system | | **Use Case** | General purpose | High-throughput APIs | ### Independent Scaling Each API can scale independently: - **Express API** scales with user activity (campaigns, canvassing) - **Media API** scales with video library size - Horizontal scaling: run multiple instances behind nginx load balancer ### Clear Service Boundaries Microservice preparation without full microservices complexity: - Shared database (PostgreSQL 16) - Shared cache (Redis) - Separate codebases (`api/src/server.ts` vs `api/src/media-server.ts`) - Future: Could split into separate repositories/deployments ## Architecture Diagram ```mermaid graph TB subgraph "Client Layer" Browser[Web Browser] Mobile[Mobile App] end subgraph "Proxy Layer" Nginx[Nginx Reverse Proxy
Port 80/443] end subgraph "API Layer" Express[Express API
Port 4000
Prisma ORM
27+ Models] Fastify[Fastify Media API
Port 4100
Drizzle ORM
Media Tables] end subgraph "Data Layer" PG[(PostgreSQL 16
changemaker_v2 DB)] Redis[(Redis 7
Cache + Queues)] end subgraph "External Services" SMTP[SMTP Server] Represent[Represent API] Geocoding[Geocoding APIs] Listmonk[Listmonk] end Browser --> Nginx Mobile --> Nginx Nginx -->|/api/* except /api/media/*| Express Nginx -->|/api/media/*| Fastify Express --> PG Express --> Redis Express --> SMTP Express --> Represent Express --> Geocoding Express --> Listmonk Fastify --> PG Fastify --> Redis style Express fill:#61dafb,stroke:#333,stroke-width:2px style Fastify fill:#00d562,stroke:#333,stroke-width:2px style PG fill:#336791,stroke:#333,stroke-width:2px style Redis fill:#dc382d,stroke:#333,stroke-width:2px ``` ## Express API (Main Features) ### Entry Point **File:** `api/src/server.ts` (234 lines) ```typescript import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import { errorHandler } from './middleware/error-handler'; import { authenticate } from './middleware/auth'; import { metricsMiddleware } from './utils/metrics'; const app = express(); // Global middleware app.use(helmet()); app.use(cors({ origin: process.env.CORS_ORIGIN, credentials: true })); app.use(express.json({ limit: '50mb' })); app.use(metricsMiddleware); // Health check (no auth) app.get('/api/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString() }); }); // Metrics endpoint (no auth, for Prometheus) app.get('/api/metrics', async (req, res) => { res.set('Content-Type', register.contentType); res.end(await register.metrics()); }); // Route registration (40+ route groups) app.use('/api/auth', authRoutes); app.use('/api/users', authenticate, usersRoutes); app.use('/api/settings', authenticate, settingsRoutes); app.use('/api/campaigns', campaignsRoutes); // Public + admin routes app.use('/api/representatives', representativesRoutes); app.use('/api/responses', responsesRoutes); // Public + admin + moderation // ... 35+ more route groups // Global error handler (must be last) app.use(errorHandler); const PORT = process.env.API_PORT || 4000; app.listen(PORT, () => { logger.info(`Express API listening on port ${PORT}`); }); ``` ### Key Features **14 Feature Modules:** 1. **auth** - JWT login, register, refresh, logout 2. **users** - User CRUD with pagination + search 3. **settings** - Site settings singleton 4. **campaigns** - Campaign CRUD + public routes 5. **representatives** - Represent API integration 6. **responses** - Response wall + moderation + upvoting 7. **email-queue** - BullMQ queue admin 8. **campaign-emails** - Email tracking + stats 9. **postal-codes** - Postal code cache 10. **locations** - Location CRUD + geocoding + NAR import 11. **cuts** - Cut (polygon) CRUD + spatial queries 12. **shifts** - Shift CRUD + signups 13. **canvass** - Volunteer canvassing (sessions, visits, routes) 14. **pages** - Landing page builder (GrapesJS) **Plus:** email-templates, listmonk, pangolin, docs, qr, services, observability ### Architecture Pattern **Layered Structure:** ``` api/src/modules/{module}/ ├── {module}.routes.ts # Express router + middleware ├── {module}.service.ts # Business logic + database queries ├── {module}.schemas.ts # Zod validation schemas └── {module}.types.ts # TypeScript interfaces (optional) ``` **Example: Campaign Module** ```typescript // campaigns.routes.ts import { Router } from 'express'; import { validate } from '../../middleware/validate'; import { authenticate, requireRole } from '../../middleware/auth'; import { createCampaignSchema, updateCampaignSchema } from './campaigns.schemas'; import * as campaignService from './campaigns.service'; const router = Router(); // Admin routes (auth required) router.post('/', authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'), validate(createCampaignSchema), async (req, res) => { const campaign = await campaignService.createCampaign(req.body, req.user!.id); res.status(201).json(campaign); } ); // Public routes (no auth) router.get('/:id', async (req, res) => { const campaign = await campaignService.getCampaignById(req.params.id); res.json(campaign); }); export default router; ``` ### ORM: Prisma **27+ Models** in `api/prisma/schema.prisma`: ```typescript model Campaign { id String @id @default(cuid()) slug String @unique title String description String? @db.Text emailSubject String emailBody String @db.Text status CampaignStatus @default(DRAFT) // Feature flags allowSmtpEmail Boolean @default(true) showResponseWall Boolean @default(true) // Audit fields createdByUserId String? createdByUser User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations emails CampaignEmail[] responses RepresentativeResponse[] customRecipients CustomRecipient[] } ``` **Connection Pooling:** Prisma manages connection pool automatically: ```typescript // prisma/schema.prisma datasource db { provider = "postgresql" url = env("DATABASE_URL") } // Default pool size: 10 connections per instance // Configure via DATABASE_URL: ?connection_limit=20 ``` ## Fastify API (Media Library) ### Entry Point **File:** `api/src/media-server.ts` (104 lines) ```typescript import Fastify from 'fastify'; import cors from '@fastify/cors'; import helmet from '@fastify/helmet'; import { videosRoutes } from './modules/media/videos/videos.routes'; import { sharedMediaRoutes } from './modules/media/shared-media/shared-media.routes'; import { jobsRoutes } from './modules/media/jobs/jobs.routes'; import { reactionsRoutes } from './modules/media/reactions/reactions.routes'; const fastify = Fastify({ logger: { level: process.env.NODE_ENV === 'production' ? 'info' : 'debug' } }); // Plugins await fastify.register(cors, { origin: process.env.CORS_ORIGIN, credentials: true }); await fastify.register(helmet); // Health check fastify.get('/health', async (request, reply) => { return { status: 'healthy', timestamp: new Date().toISOString() }; }); // Route registration fastify.register(videosRoutes, { prefix: '/api/media/videos' }); fastify.register(sharedMediaRoutes, { prefix: '/api/media/shared' }); fastify.register(jobsRoutes, { prefix: '/api/media/jobs' }); fastify.register(reactionsRoutes, { prefix: '/api/media/reactions' }); const PORT = Number(process.env.MEDIA_API_PORT) || 4100; await fastify.listen({ port: PORT, host: '0.0.0.0' }); fastify.log.info(`Fastify Media API listening on port ${PORT}`); ``` ### Key Features **4 Feature Modules:** 1. **videos** - Video CRUD, metadata, tags, deduplication 2. **shared-media** - Public gallery categories (videos, curated, compilations, etc.) 3. **jobs** - Job queue monitoring (pending, running, completed, failed) 4. **reactions** - Reaction system (6 standard emojis: like, love, laugh, wow, sad, angry) ### Architecture Pattern **Plugin-Based:** ```typescript // videos.routes.ts import { FastifyPluginAsync } from 'fastify'; import { verifyJWT } from '../../middleware/auth'; import { getVideosSchema, createVideoSchema } from './videos.schemas'; export const videosRoutes: FastifyPluginAsync = async (fastify) => { // Middleware: JWT verification fastify.addHook('onRequest', verifyJWT); // GET /api/media/videos fastify.get('/', { schema: getVideosSchema, handler: async (request, reply) => { const videos = await getVideos(request.query); return videos; } }); // POST /api/media/videos fastify.post('/', { schema: createVideoSchema, handler: async (request, reply) => { const video = await createVideo(request.body); return reply.status(201).send(video); } }); }; ``` ### ORM: Drizzle **Media Tables** in `api/src/modules/media/db/schema.ts`: ```typescript import { pgTable, serial, text, integer, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core'; export const videos = pgTable('videos', { id: serial('id').primaryKey(), path: text('path').unique().notNull(), filename: text('filename').notNull(), producer: text('producer'), creator: text('creator'), title: text('title'), durationSeconds: integer('duration_seconds'), width: integer('width'), height: integer('height'), orientation: text('orientation'), // 'landscape' | 'portrait' | 'square' hasAudio: boolean('has_audio').default(true), fileSize: integer('file_size'), thumbnailPath: text('thumbnail_path'), tags: jsonb('tags').$type(), isValid: boolean('is_valid').default(true), createdAt: timestamp('created_at').defaultNow(), }, (table) => ({ orientationIdx: index('idx_orientation').on(table.orientation), producerIdx: index('idx_producer').on(table.producer), })); ``` **Connection:** Drizzle uses the same PostgreSQL connection pool: ```typescript import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 }); export const db = drizzle(pool); ``` ## Request Flow ### Public Campaign Email Submission ```mermaid sequenceDiagram participant User as User Browser participant Nginx participant React as Admin GUI participant Express as Express API participant PG as PostgreSQL participant Redis participant BullMQ participant SMTP User->>React: Visit /campaigns/123 React->>Nginx: GET /campaigns/123 Nginx->>React: Serve React app React->>Nginx: GET /api/campaigns/123 Nginx->>Express: Forward to Express Express->>PG: SELECT campaign PG-->>Express: Campaign data Express-->>React: Campaign JSON React-->>User: Render page User->>React: Submit email form React->>Nginx: POST /api/campaigns/123/send-email Nginx->>Express: Forward to Express Express->>Express: Rate limit check (30/hour) Express->>PG: INSERT CampaignEmail Express->>BullMQ: Enqueue job BullMQ->>Redis: Add job to queue Express-->>React: Success response React-->>User: "Email queued" BullMQ->>Express: Process job (worker) Express->>PG: SELECT email + campaign Express->>Express: Build SMTP message Express->>SMTP: Send email SMTP-->>Express: Delivery confirmed Express->>PG: UPDATE status = SENT Express->>Redis: Increment cm_emails_sent_total ``` ### Admin Media Upload ```mermaid sequenceDiagram participant Admin as Admin Browser participant Nginx participant Fastify as Fastify Media API participant PG as PostgreSQL participant FS as File System Admin->>Nginx: POST /api/media/videos (10GB file) Nginx->>Fastify: Stream upload (no buffering) Fastify->>FS: Save to /media/videos/ Fastify->>PG: INSERT video metadata PG-->>Fastify: Video record Fastify-->>Admin: { id, path, thumbnail } ``` **Key Difference:** - Express handles small JSON payloads (campaigns, locations, users) - Fastify handles large file uploads (streaming, no buffering) ## Shared Resources ### PostgreSQL Database **Single Database, Multiple Schemas:** - **Prisma Tables** — Main schema (User, Campaign, Location, etc.) - **Drizzle Tables** — Media schema (videos, jobs, reactions) Both ORMs connect to the same `changemaker_v2` database: ```bash DATABASE_URL=postgresql://changemaker:password@v2-postgres:5432/changemaker_v2 ``` **No Conflicts:** - Prisma manages its own schema via migrations (`npx prisma migrate`) - Drizzle manages media tables via `npx drizzle-kit push` - Tables don't overlap (different prefixes) ### Redis Cache Both APIs use Redis for: - **Caching** — Postal codes (Express), video metadata (Fastify) - **Rate Limiting** — Redis-backed limits (Express: 30/hour, Fastify: 100/min) - **BullMQ Queues** — Email queue (Express), job queue (Fastify) ```typescript // Shared Redis connection import Redis from 'ioredis'; export const redis = new Redis({ host: 'redis-changemaker', port: 6379, password: process.env.REDIS_PASSWORD, maxRetriesPerRequest: 3 }); ``` ### JWT Authentication Both APIs verify the same JWT tokens: ```typescript // Express: api/src/middleware/auth.ts import jwt from 'jsonwebtoken'; export const authenticate = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET); req.user = payload; // { id, email, role } next(); }; // Fastify: api/src/modules/media/middleware/auth.ts import jwt from 'jsonwebtoken'; export const verifyJWT = async (request, reply) => { const token = request.headers.authorization?.split(' ')[1]; const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET); request.user = payload; }; ``` **Shared Secret:** `JWT_ACCESS_SECRET` environment variable ## Nginx Routing ### Location Block Ordering **Critical:** Media API location must come BEFORE general API location: ```nginx server { listen 80; server_name api.cmlite.org; # Media API (longest prefix first) location /api/media/ { proxy_pass http://changemaker-media-api:4100; client_max_body_size 10G; } # Express API (catch-all) location /api/ { proxy_pass http://changemaker-v2-api:4000; } } ``` **Why Order Matters:** Nginx matches longest prefix first. If `/api/` came first, it would match `/api/media/videos` and route to Express (wrong). ### Subdomain Routing (Production) ```nginx # Express API server { listen 80; server_name api.cmlite.org; location / { proxy_pass http://changemaker-v2-api:4000; } } # Fastify Media API server { listen 80; server_name media.cmlite.org; location / { proxy_pass http://changemaker-media-api:4100; } } ``` ## Performance Comparison ### Benchmarks (Internal Testing) **Simple GET Request (JSON response):** | Framework | Requests/sec | Latency p95 | Memory | |-----------|--------------|-------------|--------| | Express | 12,500 | 35ms | 150MB | | Fastify | 28,000 | 15ms | 120MB | **Large Upload (1GB file):** | Framework | Upload Time | Memory Peak | CPU Usage | |-----------|-------------|-------------|-----------| | Express | 45s | 450MB | 85% | | Fastify | 38s | 280MB | 60% | **Real-World Usage:** - Express handles 95% of requests (campaigns, users, locations) - Fastify handles 5% of requests (video uploads, media library) - Both run comfortably on single-core containers ## Future: Full Microservices The dual API design prepares for future microservices migration: ### Potential Split ``` ├── campaign-service/ # Express API (Influence module) ├── map-service/ # Express API (Map module) ├── media-service/ # Fastify API (Media module) ├── auth-service/ # Shared authentication └── api-gateway/ # Nginx or Kong ``` ### Benefits - **Independent deployment** — Ship campaign features without redeploying map - **Technology flexibility** — Use Go for high-throughput, Python for ML - **Team ownership** — Separate teams own separate services - **Fault isolation** — Media service crash doesn't affect campaigns ### Trade-offs - **Operational complexity** — More containers, more monitoring - **Network latency** — Inter-service calls over HTTP - **Data consistency** — Distributed transactions harder - **Development overhead** — Multiple repos, versioning **V2 Strategy:** Keep dual API until scaling requires split (likely 10,000+ users). ## Development Workflow ### Running Both APIs ```bash # Terminal 1: Express API cd api && npm run dev # Port 4000 # Terminal 2: Fastify Media API cd api && npm run dev:media # Port 4100 # Terminal 3: Admin GUI cd admin && npm run dev # Port 3000 ``` ### Docker Compose ```bash # Start both APIs docker compose up -d api media-api # View logs docker compose logs -f api docker compose logs -f media-api # Rebuild after dependency changes docker compose build api media-api docker compose up -d api media-api ``` ## Monitoring Both APIs expose Prometheus metrics: - **Express:** `http://localhost:4000/api/metrics` - **Fastify:** `http://localhost:4100/metrics` **Custom Metrics:** ```typescript // Express: api/src/utils/metrics.ts import client from 'prom-client'; export const httpRequestTotal = new client.Counter({ name: 'http_request_total', help: 'Total HTTP requests', labelNames: ['method', 'route', 'status'] }); export const emailsSentTotal = new client.Counter({ name: 'cm_emails_sent_total', help: 'Total campaign emails sent' }); // Fastify: api/src/modules/media/metrics.ts export const mediaUploadsTotal = new client.Counter({ name: 'cm_media_uploads_total', help: 'Total media uploads', labelNames: ['type'] }); ``` Prometheus scrapes both endpoints every 15 seconds. ## Troubleshooting ### Media API Returns 404 **Cause:** Nginx routing issue (order of location blocks). **Fix:** Ensure `/api/media/` comes BEFORE `/api/` in nginx config. ### Large Upload Fails (413) **Cause:** `client_max_body_size` too small. **Fix:** Increase in nginx config: ```nginx location /api/media/ { client_max_body_size 20G; # Increase from default } ``` ### Connection Pool Exhausted **Cause:** Too many concurrent requests, not enough DB connections. **Fix:** Increase connection limit in `DATABASE_URL`: ```bash DATABASE_URL=postgresql://user:pass@host:5432/db?connection_limit=20 ``` Or reduce pool size per API instance (if running multiple): ```typescript // Prisma datasource db { url = env("DATABASE_URL") // Add ?connection_limit=5 for smaller pool } // Drizzle const pool = new Pool({ max: 5 }); ``` ### JWT Verification Fails Across APIs **Cause:** Different `JWT_ACCESS_SECRET` values. **Fix:** Ensure both APIs use the same secret: ```bash # .env JWT_ACCESS_SECRET= ``` ## Further Reading - [Database Architecture](database.md) — Prisma vs Drizzle schemas - [Authentication Flow](authentication.md) — JWT implementation - [Monitoring Stack](monitoring.md) — Prometheus metrics - [Nginx Configuration](../deployment/nginx.md) — Reverse proxy setup - [Scaling Strategies](../deployment/scaling.md) — Horizontal scaling