Skip to content

MGSLG Analytics Platform - Architecture Decision Records (ADR)

MGSLG Analytics Platform - Architecture Decision Records (ADR)

Section titled “MGSLG Analytics Platform - Architecture Decision Records (ADR)”

Version: 1.0 Last Updated: October 2025


  1. ADR-001: FastAPI over Django/Flask
  2. ADR-002: Railway over AWS/Heroku/Azure
  3. ADR-003: Next.js over React/Vue/Angular
  4. ADR-004: PostgreSQL over MySQL/MongoDB
  5. ADR-005: Async SQLAlchemy 2.0
  6. ADR-006: Session-Based Authentication
  7. ADR-007: Tailwind CSS over Material-UI/Bootstrap
  8. ADR-008: Recharts over Chart.js/D3.js
  9. ADR-009: Monorepo Structure
  10. ADR-010: District-Level Geographic Tracking
  11. ADR-011: SACE Points 3-Year Cycle Design
  12. ADR-012: ML Scoring Algorithm (Not Neural Networks)

Status: ✅ Accepted Date: September 2024 Decision Makers: Technical Lead

Need to choose a Python web framework for building RESTful API backend.

  1. Django + Django REST Framework (DRF)
  2. Flask + Flask-RESTful
  3. FastAPI

Chosen: FastAPI

Why FastAPI:

  • Performance: ASGI-based, significantly faster than Django/Flask (3-4x faster)
  • Async Support: Native async/await for database operations
  • Auto Documentation: Built-in Swagger/OpenAPI docs at /docs
  • Type Safety: Pydantic models for request/response validation
  • Modern Python: Uses Python 3.11+ features (type hints, async)
  • Developer Experience: Auto-completion, fewer bugs, faster development

Why Not Django:

  • ❌ Heavy framework with ORM we don’t need (using SQLAlchemy)
  • ❌ Slower WSGI-based (not async)
  • ❌ More boilerplate for simple API
  • ❌ Admin panel unnecessary for this project

Why Not Flask:

  • ❌ No built-in async support (need workarounds)
  • ❌ Manual API documentation
  • ❌ Less modern (no Pydantic validation)
  • ❌ More manual work for request validation

Positive:

  • API responds in <200ms (target met)
  • Auto-generated API docs save documentation time
  • Type hints catch bugs during development
  • Easy to write async database queries

Negative:

  • Smaller ecosystem than Django/Flask
  • Team needs to learn FastAPI (learning curve ~2 days)

Mitigation:

  • FastAPI documentation is excellent
  • Community growing rapidly
  • Active development and support

Status: ✅ Accepted Date: September 2024 Decision Makers: Technical Lead

Need cloud hosting platform for production deployment with CI/CD.

  1. AWS (Elastic Beanstalk + RDS)
  2. Heroku
  3. Azure App Service
  4. Railway
  5. DigitalOcean App Platform

Chosen: Railway

Why Railway:

  • Simplicity: Zero-config deployment from GitHub
  • Cost-Effective: $5-20/month for MVP (AWS would be $50+)
  • Built-in PostgreSQL: Managed database included
  • Auto-Deploy: Push to GitHub → Auto-deploy (no manual steps)
  • Environment Variables: Easy management
  • Logs & Monitoring: Built-in dashboard
  • No DevOps Required: Perfect for small team

Why Not AWS:

  • ❌ Complex setup (VPC, security groups, load balancer, RDS)
  • ❌ Higher cost for small projects
  • ❌ Requires DevOps expertise
  • ❌ Over-engineering for demo/MVP

Why Not Heroku:

  • ❌ Expensive ($25+ for Postgres alone)
  • ❌ Sleep mode on free tier (not acceptable for demo)
  • ❌ Postgres row limits on free tier

Why Not Azure:

  • ❌ Complex pricing structure
  • ❌ Steeper learning curve
  • ❌ Overkill for project size

Why Not DigitalOcean:

  • ⚠️ Good alternative, but Railway has better GitHub integration
  • ⚠️ Requires more manual configuration

Positive:

  • Deployed production in <30 minutes
  • Zero downtime deployments
  • Automatic HTTPS certificates
  • Team can focus on features, not infrastructure

Negative:

  • Vendor lock-in (migration effort if needed)
  • Less control over infrastructure
  • Limited to Railway’s available regions

Mitigation:

  • Keep Docker configs for portability
  • Document deployment process for migration if needed
  • Railway’s pricing is stable and affordable

Status: ✅ Accepted Date: September 2024 Decision Makers: Technical Lead

Need frontend framework for building interactive analytics dashboards.

  1. React (CRA/Vite)
  2. Vue.js 3
  3. Angular
  4. Next.js 14

Chosen: Next.js 14

Why Next.js:

  • React-Based: Familiar React ecosystem
  • Server-Side Rendering (SSR): Better SEO and performance
  • File-Based Routing: No manual route configuration
  • API Routes: Can build backend routes if needed
  • Built-in Optimization: Image optimization, code splitting, lazy loading
  • TypeScript Support: First-class TypeScript integration
  • Fast Refresh: Instant feedback during development
  • Production-Ready: Used by Netflix, Uber, Nike

Why Not React (CRA/Vite):

  • ❌ Manual routing setup
  • ❌ No SSR out of the box
  • ❌ More configuration required
  • ❌ Less opinionated (more decisions to make)

Why Not Vue.js:

  • ❌ Smaller ecosystem than React
  • ❌ Less enterprise adoption
  • ❌ Team familiarity with React preferred

Why Not Angular:

  • ❌ Steeper learning curve
  • ❌ More boilerplate
  • ❌ TypeScript required (not optional)
  • ❌ Heavier framework (slower initial load)

Positive:

  • Dashboard loads in <2 seconds
  • SEO-friendly (for public pages)
  • Developer experience is excellent
  • Easy to add new pages (just create file)

Negative:

  • Next.js 14 is newer (some features in beta)
  • Server components can be confusing initially

Mitigation:

  • Use stable features for production
  • Document Next.js patterns for team
  • Leverage community resources

Status: ✅ Accepted Date: September 2024 Decision Makers: Technical Lead

Need database for storing participant, program, enrollment, and analytics data.

  1. MySQL
  2. PostgreSQL
  3. MongoDB
  4. SQLite

Chosen: PostgreSQL 15

Why PostgreSQL:

  • ACID Compliance: Strong data integrity for financial/compliance data
  • JSON Support: Can store flexible data without schema changes
  • Advanced Queries: Window functions, CTEs, array operations
  • Full-Text Search: Built-in search capabilities
  • Scalability: Handles millions of rows efficiently
  • Open Source: No licensing costs
  • Railway Support: Fully managed Postgres on Railway

Why Not MySQL:

  • ❌ Less advanced features (no window functions in older versions)
  • ❌ JSON support not as robust
  • ❌ Weaker for complex analytics queries

Why Not MongoDB:

  • ❌ NoSQL overkill for structured data
  • ❌ Lacks ACID transactions (critical for enrollments/payments)
  • ❌ More complex aggregations for analytics
  • ❌ No foreign key constraints (data integrity risk)

Why Not SQLite:

  • ❌ Not suitable for production multi-user apps
  • ❌ No concurrent writes
  • ❌ Limited scalability

Positive:

  • Complex analytics queries run efficiently
  • Data integrity guaranteed (foreign keys, constraints)
  • JSON fields allow flexible SACE metadata
  • Easy to backup and restore

Negative:

  • Slightly more complex than MySQL (advanced features)
  • Team needs to learn Postgres-specific features

Mitigation:

  • Use SQLAlchemy ORM (abstracts DB differences)
  • Document Postgres-specific queries
  • Leverage Railway’s managed Postgres (no server maintenance)

Status: ✅ Accepted Date: September 2024 Decision Makers: Technical Lead

Need ORM for database operations with FastAPI’s async capabilities.

  1. SQLAlchemy 1.4 (Sync)
  2. SQLAlchemy 2.0 (Async)
  3. Tortoise ORM
  4. Peewee

Chosen: SQLAlchemy 2.0 with asyncpg driver

Why Async SQLAlchemy 2.0:

  • Performance: Non-blocking database queries (handle 100+ concurrent requests)
  • FastAPI Integration: Works perfectly with FastAPI’s async
  • Modern API: Cleaner syntax than 1.4
  • Type Hints: Better IDE support
  • Mature: SQLAlchemy is industry standard (15+ years)
  • Asyncpg Driver: Fastest Postgres driver for Python

Why Not SQLAlchemy 1.4 (Sync):

  • ❌ Blocks event loop (slows down API)
  • ❌ Can’t handle concurrent requests efficiently
  • ❌ Older API design

Why Not Tortoise ORM:

  • ❌ Smaller community
  • ❌ Less mature (bugs more likely)
  • ❌ Limited migration tools

Why Not Peewee:

  • ❌ No async support
  • ❌ Less powerful than SQLAlchemy
  • ❌ Smaller ecosystem

Positive:

  • API can handle 50+ concurrent users
  • Database queries don’t block other requests
  • Response times stay <200ms under load

Negative:

  • Async code slightly more complex (await everywhere)
  • Debugging async can be tricky

Mitigation:

  • Use async best practices (no blocking operations)
  • Add logging for async operations
  • Test with concurrent requests
# Async query (non-blocking)
async def get_participants(db: AsyncSession, province: str):
query = select(Participant).where(Participant.province == province)
result = await db.execute(query)
return result.scalars().all()

Status: ✅ Accepted Date: September 2024 Decision Makers: Technical Lead

Need authentication for demo platform (not production-grade yet).

  1. JWT (JSON Web Tokens)
  2. OAuth 2.0 (Google/Microsoft)
  3. Session-Based (Cookies)
  4. API Keys

Chosen: Session-Based Authentication with Cookies

Why Session-Based:

  • Simple for Demo: Single password (mgslg2025)
  • Stateful: Easy to logout and revoke access
  • No Token Management: No refresh tokens, no expiry logic
  • Secure Cookies: httpOnly, secure flags prevent XSS
  • Fast to Implement: <1 day development time

Why Not JWT:

  • ❌ Overkill for single-user demo
  • ❌ Can’t revoke tokens easily (need Redis/DB)
  • ❌ More complex logout flow
  • ❌ Token expiry management needed

Why Not OAuth 2.0:

  • ❌ Way too complex for demo
  • ❌ Requires external provider setup
  • ❌ Not needed (no user accounts yet)

Why Not API Keys:

  • ❌ No UI-friendly (need to manage keys)
  • ❌ Less secure for browser apps

Positive:

  • Login works in 10 seconds
  • Logout is instant
  • No token storage in browser
  • Simple codebase

Negative:

  • Not suitable for multi-tenant production
  • Sessions stored in memory (lost on server restart)

Mitigation:

  • Phase 2: Implement JWT + Redis for production
  • Document upgrade path to proper auth
  • For demo, current solution is perfect
# Phase 2: Add JWT authentication
from fastapi_jwt_auth import AuthJWT
@router.post("/login")
async def login(credentials: LoginCredentials, Authorize: AuthJWT = Depends()):
# Verify credentials
access_token = Authorize.create_access_token(subject=user.email)
return {"access_token": access_token}

ADR-007: Tailwind CSS over Material-UI/Bootstrap

Section titled “ADR-007: Tailwind CSS over Material-UI/Bootstrap”

Status: ✅ Accepted Date: September 2024 Decision Makers: Technical Lead

Need CSS framework for building responsive dashboard UI.

  1. Material-UI (MUI)
  2. Bootstrap
  3. Ant Design
  4. Tailwind CSS

Chosen: Tailwind CSS

Why Tailwind CSS:

  • Utility-First: Rapid prototyping with utility classes
  • Customizable: Easy to match MGSLG branding
  • Small Bundle Size: Only includes used classes (PurgeCSS)
  • No JavaScript: Pure CSS (faster, simpler)
  • Responsive: Built-in breakpoints (sm, md, lg, xl)
  • Modern: Used by GitHub, Netflix, NASA

Why Not Material-UI:

  • ❌ Heavier bundle size (~200KB+)
  • ❌ Harder to customize (theme overrides complex)
  • ❌ Components have opinions (we want custom design)
  • ❌ JavaScript-based (adds complexity)

Why Not Bootstrap:

  • ❌ Outdated design patterns
  • ❌ Too opinionated (hard to make unique)
  • ❌ jQuery dependency (not needed)

Why Not Ant Design:

  • ❌ Very opinionated (Chinese design language)
  • ❌ Large bundle size
  • ❌ Harder to customize than Tailwind

Positive:

  • Custom MGSLG-branded UI in 2 days
  • Bundle size: 180KB gzipped (very small)
  • Easy to make responsive changes
  • No CSS file conflicts

Negative:

  • HTML can look cluttered with many classes
  • Learning curve for utility-first approach

Mitigation:

  • Extract common patterns to components
  • Use @apply directive for repeated patterns
  • Document design system in Storybook (future)
// Tailwind approach
<div className="bg-blue-500 text-white p-4 rounded-lg shadow-md hover:bg-blue-600 transition">
<h2 className="text-2xl font-bold">KPI Card</h2>
</div>

Status: ✅ Accepted Date: September 2024 Decision Makers: Technical Lead

Need charting library for dashboard visualizations (line charts, bar charts, pie charts).

  1. Chart.js
  2. D3.js
  3. Recharts
  4. Victory Charts
  5. Nivo

Chosen: Recharts

Why Recharts:

  • React-Native: Built for React (declarative JSX)
  • Responsive: Auto-resizes with container
  • TypeScript Support: Full type definitions
  • Composable: Build complex charts from primitives
  • Lightweight: ~50KB gzipped
  • Built on D3: Uses D3 for calculations (best of both worlds)

Why Not Chart.js:

  • ❌ Imperative API (not React-friendly)
  • ❌ Manual React integration (need react-chartjs-2 wrapper)
  • ❌ Less flexible for custom interactions

Why Not D3.js:

  • ❌ Very low-level (takes weeks to build charts)
  • ❌ Steep learning curve
  • ❌ Not React-friendly (manipulates DOM directly)
  • ❌ Overkill for standard charts

Why Not Victory:

  • ⚠️ Good alternative, but larger bundle size
  • ⚠️ Less active community than Recharts

Why Not Nivo:

  • ⚠️ Great for complex visualizations, but heavier
  • ⚠️ More opinionated (harder to customize)

Positive:

  • Charts built in hours, not days
  • Easy to add tooltips, legends, animations
  • Responsive out of the box
  • TypeScript errors caught early

Negative:

  • Limited to chart types Recharts supports
  • Custom interactions require workarounds

Mitigation:

  • Use D3 for one-off custom visualizations (if needed)
  • Recharts covers 95% of use cases
  • Community has solutions for edge cases
<ResponsiveContainer width="100%" height={300}>
<LineChart data={enrollmentTrends}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="enrollments" stroke="#3b82f6" />
</LineChart>
</ResponsiveContainer>

Status: ✅ Accepted Date: September 2024 Decision Makers: Technical Lead

Need to organize backend and frontend in same repository.

  1. Separate Repos (backend + frontend)
  2. Monorepo (single repo)

Chosen: Monorepo with /backend and /frontend directories

Why Monorepo:

  • Simplicity: One repo to clone, one place for docs
  • Shared Docs: Documentation applies to both backend/frontend
  • Atomic Commits: Backend + frontend changes in one commit
  • Easier Onboarding: New devs clone one repo
  • Shared Scripts: start_local.sh runs both services

Why Not Separate Repos:

  • ❌ Two repos to manage (more complexity)
  • ❌ Documentation scattered
  • ❌ Cross-repo changes require multiple PRs
  • ❌ Harder to keep in sync
mathew-goniwe/
├── backend/ # FastAPI + Python
├── frontend/ # Next.js + TypeScript
├── docs/ # Shared documentation
├── start_local.sh # Unified startup script
└── README.md # Project overview

Positive:

  • Single source of truth
  • Documentation colocated with code
  • Easy to run full stack locally

Negative:

  • Larger repo size
  • CI/CD needs to detect which service changed

Mitigation:

  • Railway deploys backend and frontend separately
  • Git tags can differentiate versions
  • .gitignore keeps repo size manageable

ADR-010: District-Level Geographic Tracking

Section titled “ADR-010: District-Level Geographic Tracking”

Status: ✅ Accepted Date: October 2024 Decision Makers: Technical Lead, MGSLG Stakeholders

Need to track participants at district level (not just province) for Gauteng.

  1. Province-Only Tracking
  2. District + Province (Nullable District)
  3. Separate District Table with Foreign Key
  4. Full GIS with Coordinates

Chosen: Add nullable district field to Participant model

Why Nullable District Field:

  • Simple Schema: One field, no joins needed for basic queries
  • Backward Compatible: Existing participants (no district) still work
  • Fast Queries: Indexed field, no join overhead
  • Flexible: Can add districts to other provinces later
  • Easy Filtering: WHERE province = 'Gauteng' AND district = 'Johannesburg Central'

Why Not Province-Only:

  • ❌ Not granular enough for MGSLG needs
  • ❌ Can’t analyze district-level performance

Why Not Separate District Table:

  • ❌ Over-engineering for 15 districts
  • ❌ JOIN overhead on every query
  • ❌ More complex migrations

Why Not Full GIS:

  • ❌ Massive overkill (we don’t need maps)
  • ❌ Complex queries (PostGIS extension)
  • ❌ No business requirement for coordinates

Positive:

  • 15 Gauteng districts configured in 1 hour
  • Queries stay fast (<200ms)
  • Easy to add districts for other provinces

Negative:

  • No referential integrity (districts are strings, not FKs)
  • Typos possible (mitigated with validation)

Mitigation:

  • Frontend dropdown ensures valid districts
  • Backend validation against whitelist
  • Future: Add District reference table if needed
GAUTENG_DISTRICTS = [
"Johannesburg Central", "Johannesburg East", "Johannesburg North",
"Johannesburg South", "Johannesburg West", "Ekurhuleni North",
"Ekurhuleni South", "Tshwane North", "Tshwane South", "Tshwane West",
"Gauteng East", "Gauteng North", "Gauteng West",
"Sedibeng East", "Sedibeng West"
]

Status: ✅ Accepted Date: October 2024 Decision Makers: Technical Lead, SACE Compliance Expert

SACE requires teachers to earn 150 CPTD points over 3 years. Need to track both current cycle and lifetime points.

  1. Lifetime Points Only
  2. Current Cycle Points Only
  3. Dual Tracking (Current + Lifetime)
  4. Point History Table with Cycles

Chosen: Dual tracking with cycle dates in participant_sace_points table

Why Dual Tracking:

  • Compliance Reporting: Shows current cycle progress (0-150)
  • Historical Record: Lifetime total for recognition/awards
  • Risk Assessment: Compare current cycle to expected (50 pts/year)
  • Simple Queries: No complex aggregations needed
  • Accurate Status: “On Track” vs “At Risk” calculated from cycle dates

Why Not Lifetime Only:

  • ❌ Can’t determine if participant is compliant for current cycle
  • ❌ No way to identify at-risk participants

Why Not Current Cycle Only:

  • ❌ Lose historical achievement data
  • ❌ Can’t recognize long-term participants

Why Not Point History Table:

  • ❌ Over-engineering (we have enrollments table for history)
  • ❌ Complex aggregations on every query
  • ❌ Slower performance
class ParticipantSACEPoints(Base):
total_points_earned: int # Lifetime (e.g., 245)
points_current_cycle: int # Capped at 150 per cycle
cycle_start_date: datetime # First SACE enrollment date
cycle_end_date: datetime # cycle_start + 3 years
compliance_status: str # on_track, at_risk, behind
  1. Cycle Start: First SACE-earning enrollment date
  2. Cycle Duration: Exactly 3 years (1,095 days)
  3. Cycle Cap: Maximum 150 points per cycle (excess rolls to next)
  4. Expected Rate: 50 points per year (linear trajectory)
  5. Compliance Status:
    • On Track: Current >= Expected
    • At Risk: Current >= 70% of Expected
    • Behind: Current < 70% of Expected

Positive:

  • Clear compliance reporting (meets SACE requirements)
  • Easy to identify at-risk participants
  • Fast queries (no aggregations needed)

Negative:

  • Must update cycle dates when new cycle starts
  • Edge case: What if participant skips a cycle?

Mitigation:

  • Cron job to update cycle dates annually
  • Handle skipped cycles in business logic (future)
  • Document cycle rollover process
# Participant with 2.5 years in current cycle
cycle_start = "2022-06-01"
current_date = "2025-01-01"
years_elapsed = 2.5
# Expected points: 50 points/year
expected_points = 2.5 * 50 = 125
# Actual points: 95
current_cycle_points = 95
# Status: 95 < 125 but 95 >= (125 * 0.7)
# Result: "at_risk"

ADR-012: ML Scoring Algorithm (Not Neural Networks)

Section titled “ADR-012: ML Scoring Algorithm (Not Neural Networks)”

Status: ✅ Accepted Date: October 2024 Decision Makers: Technical Lead

Need to predict career progression likelihood for participants.

  1. Rule-Based Scoring (If-Else Logic)
  2. Weighted Scoring Algorithm
  3. Logistic Regression (scikit-learn)
  4. Neural Networks (TensorFlow/PyTorch)
  5. Random Forest

Chosen: Weighted Scoring Algorithm (custom formula)

Why Weighted Scoring:

  • Transparent: Stakeholders understand the logic
  • Explainable: Can show why score is High/Medium/Low
  • No Training Data Needed: Works with current dataset
  • Deterministic: Same input = same output (reproducible)
  • Fast: Calculates in <10ms per participant
  • Customizable: Easy to adjust weights based on feedback

Why Not Rule-Based (If-Else):

  • ❌ Too rigid (can’t handle nuances)
  • ❌ Hard to maintain (many branches)

Why Not Logistic Regression:

  • ❌ Needs labeled training data (we don’t have “promoted” labels yet)
  • ❌ Less interpretable than scoring
  • ❌ Overkill for MVP

Why Not Neural Networks:

  • ❌ Extreme overkill (need 10,000+ samples)
  • ❌ Black box (stakeholders won’t trust it)
  • ❌ Slow training and inference
  • ❌ Requires GPUs for reasonable performance

Why Not Random Forest:

  • ⚠️ Good middle ground, but needs labeled data
  • ⚠️ Less interpretable than scoring
progression_score = (
participation_weight * 0.30 + # Programs completed (0-30)
completion_weight * 0.20 + # Completion rate (0-20)
experience_weight * 0.15 + # Years of experience (0-15)
performance_weight * 0.35 # Avg feedback rating (0-35)
)
# Total: 0-100
# Likelihood classification:
# 80-100: High (6-12 months to promotion)
# 60-79: Medium (12-18 months)
# 0-59: Low (18+ months)

Positive:

  • Stakeholders trust the predictions (can explain)
  • No training phase (works immediately)
  • Easy to tune based on real outcomes
  • Faster than ML models

Negative:

  • Not as accurate as trained ML (when we have data)
  • Weights are somewhat arbitrary (educated guesses)

Mitigation:

  • Phase 2: Collect promotion outcome data
  • Phase 3: Train logistic regression with real labels
  • Phase 4: Compare scoring vs ML accuracy
  • Adjust weights based on real-world feedback
# Phase 3: When we have labeled data
from sklearn.linear_model import LogisticRegression
X = [participation, completion, experience, performance]
y = [1 if promoted else 0] # Labels from career_updates table
model = LogisticRegression()
model.fit(X, y)
# Predict probability of promotion
promotion_probability = model.predict_proba(X_new)[0][1]

ADRDecisionWhy?
001FastAPIPerformance, async, auto-docs
002RailwaySimplicity, cost, auto-deploy
003Next.jsSSR, routing, React ecosystem
004PostgreSQLACID, JSON, analytics queries
005Async SQLAlchemyNon-blocking, concurrency
006Session AuthSimple for demo, upgrade later
007Tailwind CSSUtility-first, customizable, small bundle
008RechartsReact-native, declarative, easy
009MonorepoSingle source of truth, shared docs
010District FieldSimple, fast, backward-compatible
011Dual SACE TrackingCompliance + history
012Scoring AlgorithmExplainable, fast, no training needed

VersionDateChanges
1.0Oct 2025Initial ADRs (001-012)

Architecture decisions evolve. Update this document when technical choices change.