Building a university alumni platform sounds deceptively pedestrian on paper: "It's just user profiles, a job board, and an events calendar." But when you factor in 50 graduating classes dating back to 1974, complex multi-tenant role permissions, high-frequency donation matching, and virtual reunion events that spike from 50 to 8,000 concurrent users within minutes, standard CRUD assumptions evaporate. During our very first reunion week launch, unindexed full-text queries against 50,000 alumni profiles locked up PostgreSQL, exhausting connection pools and taking the service down.
This case study documents how we redesigned the platform into a Hybrid Monolith-Microservice Architecture. By anchoring core relational data in Django, offloading real-time WebSocket fan-out to a lightweight Flask daemon, deploying targeted partial PostgreSQL indexes, and engineering a Redis write-through cache with pattern invalidation, we dropped P50 query latency to 18ms and survived 8,200 concurrent users on a single $40/month host.
1. The Scale Problem: Three Conflicting Access Profiles
The core challenge in university community platforms is that different user personas place completely contradictory demands on the database:
- The Alumni Persona (Read-Heavy & Filter-Intensive): Searches for classmates by graduation year, major, employer, and geographic city (e.g. "All 2018 Computer Science alumni at Microsoft in Seattle"). Requires instant multi-column filtering across 50,000 rows without full-table scans.
- The Student Persona (High Write-Burst Concurrency): Applies for campus referral postings, RSVPs for mentorship seminars, and submits questions to forums. Spikes heavily during placement season (September–November).
- The University Admin (Heavy Compute & Aggregates): Generates cross-batch endowment donation reports, exports audit logs, and triggers bulk emails to 10,000+ recipients. These queries lock tables if run on the primary read/write database instance.
Zero data loss tolerance for alumni financial contributions, strict Role-Based Access Control (RBAC) preventing unverified profiles from contacting alumni, and soft-delete retention (deleted_at timestamps) for university historical archiving.
2. The Hybrid Architecture: Django Core + Flask Real-Time
Rather than introducing full microservice complexity (which introduces distributed tracing, inter-service networking latency, and complex saga transactions), we adopted a Hybrid Monolith:
System Topology & Request Flow
- TIER 01 Nginx Gateway SSL termination, rate limiting (100 req/min), path routing
- TIER 02 Django Core Auth, RBAC, REST APIs, Celery workers, relational data
- TIER 03 Flask Events Socket.IO daemon for reunion chat & live RSVP counters
- TIER 04 Data Layer PostgreSQL primary + Redis cache / message queue
Cross-service authentication is decoupled via Stateless HMAC-SHA256 JWTs. When a user authenticates at the Django endpoint, the token payload encapsulates their user ID, verification status, and role permissions. The Flask WebSocket service verifies the JWT signature locally using a shared environment secret, eliminating inter-service database lookups on every chat message.
3. Database Optimization & Partial Indexing
In our initial schema, searching alumni filtered by batch_year and company took 3,200ms because PostgreSQL executed sequential scans across all columns. We analyzed query execution plans with EXPLAIN ANALYZE and applied targeted compound and partial indexes:
from django.db import models
from django.db.models import Q
from django.contrib.auth.models import User
class AlumniProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
batch_year = models.IntegerField(db_index=True)
department = models.CharField(max_length=50)
company = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True)
industry = models.CharField(max_length=100)
is_verified = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
# Compound Index: Matches 85% of directory filter queries
models.Index(
fields=['batch_year', 'company'],
name='batch_company_idx'
),
# Partial Index: Indexes ONLY verified, non-deleted alumni profiles.
# Reduces index footprint by 38% and avoids indexing inactive rows.
models.Index(
fields=['batch_year', 'department'],
name='verified_active_idx',
condition=Q(is_verified=True, deleted_at__isnull=True)
),
]
ordering = ['-batch_year']
Query Optimization Benchmarks
| Query Operation | Unindexed (Baseline) | Optimized (Partial Index) | Speedup |
|---|---|---|---|
| Directory search (batch + company) | 3,200 ms | 12 ms | 266x faster |
| Verified alumni directory browse | 1,800 ms | 8 ms | 225x faster |
| Full-text skill / bio search | 4,500 ms | 45 ms | 100x faster |
| Annual donation aggregate sum | 12,000 ms | 350 ms | 34x faster |
4. Caching Strategy: Redis Write-Through with Invalidation
Indexes optimized database reads, but hitting PostgreSQL for static directory views still depleted connection pools. We wrapped repetitive directory queries in a Redis write-through cache decorator with programmatic signal-based cache purging:
import redis
import json
import hashlib
from functools import wraps
from django.db.models.signals import post_save
from django.dispatch import receiver
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
CACHE_TTL = 300 # 5-minute time-to-live
def cache_query(key_prefix: str, ttl: int = CACHE_TTL):
"""Decorator: Caches expensive database reads into Redis."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Compute unique cache key based on query arguments
raw_key = f"{key_prefix}:{str(args)}:{str(kwargs)}"
cache_key = f"{key_prefix}:{hashlib.md5(raw_key.encode()).hexdigest()}"
cached = r.get(cache_key)
if cached:
return json.loads(cached) # Cache HIT: 0ms DB time
result = func(*args, **kwargs)
r.setex(cache_key, ttl, json.dumps(result))
return result
return wrapper
return decorator
# Automatically purge stale directory keys on profile updates
@receiver(post_save, sender=AlumniProfile)
def invalidate_alumni_cache(sender, instance, **kwargs):
# Scan and purge all cached query variants for this prefix
for key in r.scan_iter(match="alumni_search:*"):
r.delete(key)
5. ACID Financial Integrity with SELECT FOR UPDATE
Handling alumni donations requires zero tolerance for race conditions. If two batchmates donate ₹50,000 simultaneously toward a batch endowment goal, concurrent updates could overwrite the running fund counter.
We wrapped webhook ingest in transaction.atomic() paired with row-level pessimistic locking via select_for_update():
from django.db import transaction
from django.http import JsonResponse
import json
@transaction.atomic
def process_donation_webhook(payment_id: str, fund_id: int, amount: float, alumni_id: int):
"""
Executes all financial mutations in a single atomic transaction.
If receipt generation or counter updates fail, all steps roll back.
"""
# 1. Create immutable audit ledger record
donation = DonationRecord.objects.create(
alumni_id=alumni_id,
amount=amount,
payment_gateway_ref=payment_id,
status="CONFIRMED"
)
# 2. Acquire pessimistic row-lock on fund counter (prevents lost updates)
fund = EndowmentFund.objects.select_for_update().get(id=fund_id)
fund.current_balance += amount
fund.save()
# 3. Queue asynchronous PDF tax receipt generation in Celery
generate_tax_receipt.delay(donation.id)
return {"status": "SUCCESS", "donation_id": donation.id}
6. Flask WebSocket Event Architecture
Reunion chat streams and live RSVP attendance counters run on a separate Flask service powered by Socket.IO. This cleanly isolates long-lived persistent WebSocket connections from Django's synchronous WSGI worker pool, preventing connection starvation.
7. Locust Stress Benchmarks & Results
Prior to launch, we simulated Alumni Reunion Day traffic using Locust across a single 4 vCPU, 8 GB RAM server running Docker Compose:
8. Architectural Lessons & Takeaways
Index Query Patterns, Not Column Schemas
Blindly adding indexes to every column wastes disk space and slows down write operations. Analyzing query access logs revealed that 90% of requests filtered on compound fields, allowing us to build surgical compound and partial indexes.
Hybrid Monolith Over Premature Microservices
Splitting everything into 8 microservices would have introduced distributed transaction pain. Keeping relational domain models in Django while isolating real-time WebSockets to Flask gave us the exact balance of velocity and scale.
Redis Write-Through with Signal Purging
Achieving a 94% cache hit rate during peak traffic allowed a modest single-host server to handle 8,200 concurrent users without database CPU spikes.
Pessimistic Locks for Financial Mutations
Optimistic concurrency models fail when donation traffic spikes in bursts. Row-level select_for_update() locks ensure mathematical precision on public fundraising targets.
- Stack
- Django 4.2, Flask, PostgreSQL 15, Redis 7, Celery, Docker Compose, Nginx
- Key Architecture
- Hybrid Monolith, Partial Indexes, Pessimistic Locking, Socket.IO