20 KiB
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.tsvsapi/src/media-server.ts) - Future: Could split into separate repositories/deployments
Architecture Diagram
graph TB
subgraph "Client Layer"
Browser[Web Browser]
Mobile[Mobile App]
end
subgraph "Proxy Layer"
Nginx[Nginx Reverse Proxy<br/>Port 80/443]
end
subgraph "API Layer"
Express[Express API<br/>Port 4000<br/>Prisma ORM<br/>27+ Models]
Fastify[Fastify Media API<br/>Port 4100<br/>Drizzle ORM<br/>Media Tables]
end
subgraph "Data Layer"
PG[(PostgreSQL 16<br/>changemaker_v2 DB)]
Redis[(Redis 7<br/>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)
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:
- auth - JWT login, register, refresh, logout
- users - User CRUD with pagination + search
- settings - Site settings singleton
- campaigns - Campaign CRUD + public routes
- representatives - Represent API integration
- responses - Response wall + moderation + upvoting
- email-queue - BullMQ queue admin
- campaign-emails - Email tracking + stats
- postal-codes - Postal code cache
- locations - Location CRUD + geocoding + NAR import
- cuts - Cut (polygon) CRUD + spatial queries
- shifts - Shift CRUD + signups
- canvass - Volunteer canvassing (sessions, visits, routes)
- 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
// 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:
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:
// 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)
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:
- videos - Video CRUD, metadata, tags, deduplication
- shared-media - Public gallery categories (videos, curated, compilations, etc.)
- jobs - Job queue monitoring (pending, running, completed, failed)
- reactions - Reaction system (6 standard emojis: like, love, laugh, wow, sad, angry)
Architecture Pattern
Plugin-Based:
// 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:
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<string[]>(),
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:
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
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
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:
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)
// 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:
// 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:
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)
# 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
# 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
# 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:
// 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:
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:
DATABASE_URL=postgresql://user:pass@host:5432/db?connection_limit=20
Or reduce pool size per API instance (if running multiple):
// 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:
# .env
JWT_ACCESS_SECRET=<same-value-for-both>
Further Reading
- Database Architecture — Prisma vs Drizzle schemas
- Authentication Flow — JWT implementation
- Monitoring Stack — Prometheus metrics
- Nginx Configuration — Reverse proxy setup
- Scaling Strategies — Horizontal scaling