Scalable LMS Architecture: Multi-Tenant Patterns
The global Learning Management System (LMS) market surpassed $28 billion in 2025, with projections approaching $70 billion by 2030. Behind platforms like Moodle, Canvas, Docebo, and Coursera lie enormous architectural challenges: serving thousands of organizations simultaneously, guaranteeing data isolation, scaling horizontally during usage peaks, and maintaining sub-second latencies for millions of concurrently connected students.
Multi-tenancy is the architectural pattern that makes all of this possible. In a multi-tenant LMS, a single application instance serves multiple organizations (tenants), each with their own users, courses, content, and configurations, while sharing the underlying infrastructure. Choosing the right multi-tenant pattern determines operational costs, security, performance, and the ability to scale from 10 to 10,000 tenants without rewriting the system.
In this inaugural article of the EdTech Engineering series, we will deeply analyze multi-tenant architectures for LMS platforms, comparing data isolation patterns, scaling strategies, optimized data models, and event-driven patterns for real-time features. Every concept will be accompanied by concrete code examples in TypeScript and Python.
What You Will Learn in This Article
- The three fundamental multi-tenancy patterns and when to use each
- How to design an LMS data model that scales with thousands of tenants
- Authentication and authorization in multi-tenant contexts with JWT and RBAC
- REST and GraphQL API design for tenant resolution
- Event-driven architecture for real-time notifications and analytics
- Microservices decomposition of an enterprise LMS platform
- Comparison of Moodle, Canvas, Blackboard, and modern platform architectures
- Performance benchmarks and caching strategies for high-traffic LMS
EdTech Engineering Series Overview
| # | Article | Focus |
|---|---|---|
| 1 | You are here - Scalable LMS Architecture | Multi-tenant patterns for LMS platforms |
| 2 | Educational Video Streaming | Video content delivery architecture |
| 3 | Learning Analytics and xAPI | Learning data collection and analysis |
| 4 | SCORM and Content Packaging | Standards for educational content |
| 5 | Adaptive Learning with AI | Personalized learning paths |
| 6 | Platform Gamification | Badges, leaderboards and motivation |
| 7 | Proctoring and Assessment | Secure online exams and anti-fraud |
| 8 | Real-Time Collaboration | Shared whiteboards and collaborative editing |
| 9 | Mobile and Offline Learning | PWA and offline synchronization |
| 10 | LLM as Virtual Tutor | Integrating language models in e-learning |
The Learning Management System Landscape
A modern LMS is no longer a simple course repository with a tracking system. Today's platforms integrate adaptive video streaming, interactive assessments, predictive analytics, real-time collaboration, and increasingly, AI-powered tutors. This functional complexity translates directly into architectural complexity.
LMS platforms fall into three macro-categories based on deployment model:
- Self-hosted (on-premise): Moodle, Open edX. The organization manages the entire infrastructure. Maximum control, maximum operational overhead.
- SaaS multi-tenant: Canvas Cloud, TalentLMS, Docebo. The vendor manages everything. Each tenant has their own logically isolated space.
- PaaS/Hybrid: Blackboard Learn Ultra, Brightspace. Vendor-managed infrastructure with advanced customization options and private deployments.
The dominant trend in 2025-2026 is toward cloud-native multi-tenant architectures, with 34% year-over-year growth for SaaS platforms compared to 7% for on-premise solutions. This shift is driven by the need to reduce operational costs, accelerate time-to-market for new features, and serve a growing number of organizations with lean engineering teams.
Key Challenges for Multi-Tenant LMS Platforms
- Data isolation: One university's data must never be accessible by another
- Noisy neighbor: A tenant with 50,000 students must not degrade performance for smaller tenants
- Customization: Each tenant wants different branding, workflows, and integrations
- Compliance: GDPR, FERPA, SOC 2 require specific guarantees on data residency and processing
- Non-uniform scalability: Usage peaks are seasonal (semester start, exams) and vary by tenant
The Three Multi-Tenancy Patterns
The choice of multi-tenant pattern is the most critical architectural decision in LMS design. Three fundamental approaches exist, each with a different profile of isolation, cost, and complexity.
Pattern 1: Database per Tenant (Silo Model)
Each tenant receives a dedicated database. This approach offers the highest level of isolation: data is physically separated, one tenant's performance doesn't affect others, and backup/restore operations are independent. It's the preferred pattern for platforms serving large enterprises with stringent compliance requirements.
- Isolation: Maximum. Complete physical data separation.
- Cost: High. Each database consumes dedicated resources (connections, storage, backups).
- Complexity: High. Schema migrations must be executed on every database.
- Scalability: Linear but expensive. Each new tenant = new infrastructure.
- Ideal use case: Enterprise with fewer than 100 large organizations, FERPA/HIPAA requirements.
Pattern 2: Schema per Tenant (Bridge Model)
All tenants share the same database instance, but each has their own schema (namespace). In PostgreSQL, for example, each tenant operates within its own schema with identical tables but isolated data. This approach balances isolation and operational cost.
- Isolation: Strong. Logical separation at the schema level. Cross-contamination impossible without explicit errors.
- Cost: Medium. A single database cluster serves all tenants.
- Complexity: Medium. Migrations require iteration across all schemas, but can be automated.
- Scalability: Good up to ~500-1,000 schemas per PostgreSQL instance, then sharding is needed.
- Ideal use case: SaaS with 50-1,000 medium-sized tenants, need for per-schema customization.
Pattern 3: Shared Schema with Tenant ID (Pool Model)
All tenants share the same tables. Isolation is guaranteed by a tenant_id column
present in every table and by Row-Level Security (RLS) at the database level. It's the
most cost-efficient pattern and the simplest to manage operationally, but requires rigorous
discipline in application code.
- Isolation: Logical. Depends on RLS and application-level validation. A code bug can expose cross-tenant data.
- Cost: Low. A single database instance serves thousands of tenants.
- Complexity: Low for migrations (single schema), high for security (every query must include the tenant filter).
- Scalability: Excellent. Supports thousands of tenants with tenant_id-based sharding (Citus for PostgreSQL).
- Ideal use case: High-volume SaaS with thousands of small/medium-sized tenants.
Pattern Comparison
| Criterion | Database per Tenant | Schema per Tenant | Shared Schema + RLS |
|---|---|---|---|
| Data isolation | Physical (maximum) | Logical (strong) | Logical (RLS) |
| Cost per tenant | High ($50-200/month) | Medium ($10-50/month) | Low ($1-10/month) |
| Schema migrations | N executions (1 per DB) | N executions (1 per schema) | 1 execution |
| Recommended max tenants | 50-200 | 500-2,000 | 10,000+ |
| Noisy neighbor risk | None | Medium (shared I/O) | High (mitigable with sharding) |
| FERPA/GDPR compliance | Excellent | Good | Requires additional audits |
| Operational complexity | High | Medium | Low |
| Schema customization | Full | Per-schema | Limited (custom fields) |
Tenant Resolution: Middleware and Strategies
In a multi-tenant system, every HTTP request must be associated with the correct tenant before any application logic executes. This process, called tenant resolution, is the first layer of the architecture and a critical security point. If resolution fails or is bypassed, the entire isolation collapses.
Resolution Strategies
Four primary strategies exist for identifying the tenant of a request:
- Subdomain:
mit.lms-platform.com,stanford.lms-platform.com. The most common, requires wildcard DNS configuration. - Path prefix:
lms-platform.com/tenants/mit/courses. Simple, but pollutes API paths. - HTTP header:
X-Tenant-ID: mit-university. Flexible for machine-to-machine APIs. - JWT claim: The tenant is encoded in the authentication token. Secure, but requires re-authentication to switch tenants.
Resolution Middleware Implementation (TypeScript/Express)
The following Express middleware implements a combined strategy: subdomain as primary, HTTP header as fallback, with validation against a tenant registry.
import { Request, Response, NextFunction } from 'express';
import { LRUCache } from 'lru-cache';
// Immutable tenant interface
interface TenantConfig {
readonly id: string;
readonly slug: string;
readonly dbSchema: string;
readonly plan: 'free' | 'pro' | 'enterprise';
readonly region: 'eu-west-1' | 'us-east-1' | 'ap-southeast-1';
readonly features: ReadonlyArray<string>;
readonly maxUsers: number;
readonly isActive: boolean;
}
// Cache to avoid DB lookup on every request
const tenantCache = new LRUCache<string, TenantConfig>({
max: 5000,
ttl: 1000 * 60 * 5, // 5 minutes
});
// Repository for tenant lookups
interface TenantRepository {
findBySlug(slug: string): Promise<TenantConfig | null>;
}
export function createTenantMiddleware(repo: TenantRepository) {
return async (req: Request, res: Response, next: NextFunction) => {
// 1. Extract slug from subdomain
const host = req.hostname;
let tenantSlug = extractSubdomain(host);
// 2. Fallback: X-Tenant-ID header
if (!tenantSlug) {
tenantSlug = req.headers['x-tenant-id'] as string | undefined
?? null;
}
// 3. Validation: slug is required
if (!tenantSlug) {
res.status(400).json({
error: 'TENANT_NOT_RESOLVED',
message: 'Unable to determine tenant from request',
});
return;
}
// 4. Cached lookup
let config = tenantCache.get(tenantSlug) ?? null;
if (!config) {
config = await repo.findBySlug(tenantSlug);
if (config) {
tenantCache.set(tenantSlug, config);
}
}
// 5. Tenant not found or inactive
if (!config || !config.isActive) {
res.status(404).json({
error: 'TENANT_NOT_FOUND',
message: `Tenant '${tenantSlug}' not found or inactive`,
});
return;
}
// 6. Inject tenant context into request
(req as any).tenantConfig = config;
(req as any).tenantId = config.id;
next();
};
}
function extractSubdomain(host: string): string | null {
const parts = host.split('.');
// e.g.: "mit.lms-platform.com" -> ["mit", "lms-platform", "com"]
if (parts.length >= 3) {
const subdomain = parts[0];
// Exclude system subdomains
const reserved = ['www', 'api', 'admin', 'status'];
return reserved.includes(subdomain) ? null : subdomain;
}
return null;
}
Python Implementation (FastAPI)
For those using FastAPI, here's the equivalent with dependency injection for tenant resolution:
from fastapi import Request, HTTPException, Depends
from functools import lru_cache
from dataclasses import dataclass, field
from typing import Optional
import asyncio
@dataclass(frozen=True)
class TenantConfig:
"""Immutable tenant configuration."""
id: str
slug: str
db_schema: str
plan: str # 'free' | 'pro' | 'enterprise'
region: str
features: tuple[str, ...] = field(default_factory=tuple)
max_users: int = 100
is_active: bool = True
class TenantResolver:
"""Resolves tenant from HTTP request."""
def __init__(self, repository):
self._repo = repository
self._cache: dict[str, TenantConfig] = {}
async def resolve(self, request: Request) -> TenantConfig:
# 1. Subdomain
slug = self._extract_subdomain(request.headers.get("host", ""))
# 2. Fallback: header
if not slug:
slug = request.headers.get("x-tenant-id")
if not slug:
raise HTTPException(
status_code=400,
detail="Unable to determine tenant from request"
)
# 3. Cache lookup
if slug in self._cache:
config = self._cache[slug]
else:
config = await self._repo.find_by_slug(slug)
if config:
self._cache[slug] = config
if not config or not config.is_active:
raise HTTPException(
status_code=404,
detail=f"Tenant '{slug}' not found or inactive"
)
return config
@staticmethod
def _extract_subdomain(host: str) -> Optional[str]:
parts = host.split(".")
if len(parts) >= 3:
subdomain = parts[0]
reserved = {"www", "api", "admin", "status"}
return None if subdomain in reserved else subdomain
return None
# Dependency injection in FastAPI
async def get_tenant(request: Request) -> TenantConfig:
resolver = request.app.state.tenant_resolver
return await resolver.resolve(request)
Data Model for a Multi-Tenant LMS
An LMS data model must capture complex entities and their relationships: users, organizations, courses, modules, lessons, assessments, submissions, enrollments, progress, and certificates. In a multi-tenant context, every entity must be associated with its owning tenant, and queries must be efficient for both single-tenant operations (the norm) and cross-tenant operations (administrative reporting).
Core Database Schema
The following SQL schema represents the core of a multi-tenant LMS with the "shared schema + tenant_id" pattern. It uses PostgreSQL with Row-Level Security (RLS) to guarantee isolation.
-- ============================================
-- TENANT TABLE (organization registry)
-- ============================================
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(63) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
plan VARCHAR(20) NOT NULL DEFAULT 'free',
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
-- ============================================
-- USERS TABLE
-- ============================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
email VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'student',
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, email)
);
-- ============================================
-- COURSES TABLE
-- ============================================
CREATE TABLE courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
instructor_id UUID NOT NULL REFERENCES users(id),
title VARCHAR(500) NOT NULL,
slug VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
max_enrollment INTEGER,
settings JSONB NOT NULL DEFAULT '{}',
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, slug)
);
-- ============================================
-- MODULES AND LESSONS (hierarchical structure)
-- ============================================
CREATE TABLE modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content_type VARCHAR(30) NOT NULL, -- 'video', 'text', 'quiz', 'assignment'
content_ref TEXT, -- URL or content ID
duration_min INTEGER,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================
-- ENROLLMENTS
-- ============================================
CREATE TABLE enrollments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
user_id UUID NOT NULL REFERENCES users(id),
course_id UUID NOT NULL REFERENCES courses(id),
status VARCHAR(20) NOT NULL DEFAULT 'active',
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
UNIQUE (tenant_id, user_id, course_id)
);
-- ============================================
-- PROGRESS (lesson completion tracking)
-- ============================================
CREATE TABLE lesson_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
user_id UUID NOT NULL REFERENCES users(id),
lesson_id UUID NOT NULL REFERENCES lessons(id),
status VARCHAR(20) NOT NULL DEFAULT 'not_started',
progress_pct SMALLINT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
UNIQUE (tenant_id, user_id, lesson_id)
);
-- ============================================
-- ASSESSMENTS AND SUBMISSIONS
-- ============================================
CREATE TABLE assessments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
course_id UUID NOT NULL REFERENCES courses(id),
title VARCHAR(500) NOT NULL,
assessment_type VARCHAR(30) NOT NULL, -- 'quiz', 'exam', 'assignment', 'peer_review'
max_score DECIMAL(6,2) NOT NULL DEFAULT 100,
passing_score DECIMAL(6,2) NOT NULL DEFAULT 60,
time_limit_min INTEGER,
max_attempts INTEGER DEFAULT 1,
questions JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
assessment_id UUID NOT NULL REFERENCES assessments(id),
user_id UUID NOT NULL REFERENCES users(id),
attempt_number SMALLINT NOT NULL DEFAULT 1,
answers JSONB NOT NULL DEFAULT '{}',
score DECIMAL(6,2),
status VARCHAR(20) NOT NULL DEFAULT 'submitted',
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
graded_at TIMESTAMPTZ
);
-- ============================================
-- ROW-LEVEL SECURITY (RLS)
-- ============================================
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE courses ENABLE ROW LEVEL SECURITY;
ALTER TABLE enrollments ENABLE ROW LEVEL SECURITY;
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;
ALTER TABLE submissions ENABLE ROW LEVEL SECURITY;
-- Policy: each user sees only their own tenant's data
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_courses ON courses
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_enrollments ON enrollments
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- INDEXES for multi-tenant performance
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_courses_tenant ON courses(tenant_id);
CREATE INDEX idx_enrollments_tenant_user ON enrollments(tenant_id, user_id);
CREATE INDEX idx_enrollments_tenant_course ON enrollments(tenant_id, course_id);
CREATE INDEX idx_lesson_progress_tenant_user ON lesson_progress(tenant_id, user_id);
CREATE INDEX idx_submissions_tenant_assessment ON submissions(tenant_id, assessment_id);
Warning: Multi-Column Indexes with Tenant ID
In an LMS with shared schema, every index must include tenant_id as the first column.
An index on (email) is useless for tenant-filtered queries; you need (tenant_id, email).
With Citus/distributed sharding, the distribution column (tenant_id) must be present
in every composite primary key and every UNIQUE constraint.
Entity Relationship Diagram
| Entity | Relationship | Related Entity | Cardinality |
|---|---|---|---|
| Tenant | owns | Users, Courses | 1:N |
| User | enrolls in | Course (via Enrollment) | N:M |
| Course | contains | Modules | 1:N |
| Module | contains | Lessons | 1:N |
| Course | has | Assessments | 1:N |
| User | submits | Submissions (via Assessment) | 1:N |
| User | tracks progress | Lesson Progress (via Lesson) | 1:N |
Multi-Tenant Authentication and Authorization
In a multi-tenant LMS, authentication must solve two problems simultaneously: verify the user's identity and determine which tenant they belong to. Authorization adds a third layer: which resources the user can access within their tenant.
JWT with Tenant Claim
The most common approach uses JSON Web Tokens (JWT) with a dedicated tenant claim. The token is issued after authentication and contains all the information needed for context resolution without additional database lookups.
import jwt from 'jsonwebtoken';
// Immutable JWT payload
interface LmsTenantJwtPayload {
readonly sub: string; // User ID
readonly tenantId: string; // Tenant UUID
readonly tenantSlug: string; // For display and logging
readonly role: 'student' | 'instructor' | 'admin' | 'super_admin';
readonly permissions: ReadonlyArray<string>;
readonly iat: number;
readonly exp: number;
}
// Token generation with tenant context
function generateTenantToken(
user: { id: string; role: string },
tenant: { id: string; slug: string },
permissions: ReadonlyArray<string>
): string {
const payload: Omit<LmsTenantJwtPayload, 'iat' | 'exp'> = {
sub: user.id,
tenantId: tenant.id,
tenantSlug: tenant.slug,
role: user.role as LmsTenantJwtPayload['role'],
permissions,
};
return jwt.sign(payload, process.env.JWT_SECRET!, {
expiresIn: '8h',
issuer: 'lms-platform',
audience: tenant.slug,
});
}
// Validation middleware
function validateTenantToken(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'MISSING_TOKEN' });
}
try {
const token = authHeader.slice(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
issuer: 'lms-platform',
}) as LmsTenantJwtPayload;
// Verify tenant consistency between URL and token
const urlTenantId = (req as any).tenantId;
if (urlTenantId && decoded.tenantId !== urlTenantId) {
return res.status(403).json({
error: 'TENANT_MISMATCH',
message: 'Token tenant does not match request tenant',
});
}
(req as any).user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'INVALID_TOKEN' });
}
}
RBAC Model for LMS
Authorization in an LMS typically follows a Role-Based Access Control (RBAC) model with four main roles, each with cumulative permissions:
| Role | Key Permissions | Scope |
|---|---|---|
| Student | View enrolled courses, submit assignments, view own grades | Only courses they're enrolled in |
| Instructor | Create/edit courses, grade submissions, view analytics for own courses | Only their own courses |
| Admin | Manage users, configure tenant, view all courses, export reports | Entire tenant |
| Super Admin | Manage tenants, billing, feature flags, cross-tenant support | All tenants (platform operator) |
API Design for Multi-Tenant LMS
The APIs of a multi-tenant LMS must balance developer experience for client consumers (frontend, mobile, integrations) with rigorous security in data isolation. The choice between REST and GraphQL depends on predominant access patterns.
REST API: Multi-Tenancy Conventions
In a REST architecture for LMS, the tenant is resolved by middleware (subdomain or header), so resource paths don't include the tenant ID. This simplifies APIs and makes them portable across environments.
# Tenant resolved via subdomain: mit.lms-api.com
# tenant_id injected by middleware, NOT present in paths
# === COURSES ===
GET /api/v1/courses # List tenant's courses
GET /api/v1/courses/:courseId # Course details
POST /api/v1/courses # Create course (instructor+)
PUT /api/v1/courses/:courseId # Update course
DELETE /api/v1/courses/:courseId # Archive course (admin+)
# === ENROLLMENTS ===
POST /api/v1/courses/:courseId/enroll # Enroll current user
GET /api/v1/enrollments # My enrollments
GET /api/v1/courses/:courseId/students # Enrolled students (instructor+)
# === CONTENT ===
GET /api/v1/courses/:courseId/modules # Course modules
GET /api/v1/modules/:moduleId/lessons # Module lessons
PUT /api/v1/lessons/:lessonId/progress # Update progress
# === ASSESSMENT ===
GET /api/v1/courses/:courseId/assessments # Course assessments
POST /api/v1/assessments/:assessmentId/submissions # Submit answer
GET /api/v1/submissions/:submissionId # Submission details
# === ANALYTICS (instructor/admin) ===
GET /api/v1/analytics/courses/:courseId/completion # Completion rates
GET /api/v1/analytics/tenant/overview # Tenant overview
GraphQL: Flexible Queries for the Frontend
GraphQL is particularly effective for LMS clients because pages often require complex aggregations: a student dashboard needs enrolled courses, progress, upcoming assignments, and notifications in a single request.
# Tenant context automatically injected by middleware
# Every resolver filters by tenant_id from context
type Query {
# Courses available for the current tenant
courses(
status: CourseStatus
search: String
pagination: PaginationInput
): CourseConnection!
# Course details (only if in current tenant)
course(id: ID!): Course
# Student dashboard: aggregated data in a single query
myDashboard: StudentDashboard!
# Analytics for instructors
courseAnalytics(courseId: ID!): CourseAnalytics!
}
type Course {
id: ID!
title: String!
description: String
instructor: User!
modules: [Module!]!
enrollmentCount: Int!
completionRate: Float
status: CourseStatus!
createdAt: DateTime!
}
type StudentDashboard {
enrolledCourses: [EnrolledCourseProgress!]!
upcomingAssessments: [Assessment!]!
recentActivity: [ActivityEvent!]!
overallProgress: Float!
certificatesEarned: Int!
}
type EnrolledCourseProgress {
course: Course!
progressPercent: Float!
lastAccessedAt: DateTime
nextLesson: Lesson
}
type Mutation {
enrollInCourse(courseId: ID!): Enrollment!
updateLessonProgress(lessonId: ID!, progress: Int!): LessonProgress!
submitAssessment(assessmentId: ID!, answers: JSON!): Submission!
createCourse(input: CreateCourseInput!): Course!
}
type Subscription {
# Real-time notifications for students
studentNotifications: Notification!
# Live progress updates (for instructor dashboard)
courseProgressUpdates(courseId: ID!): ProgressUpdate!
}
Content Delivery and Media Streaming
An LMS manages heterogeneous content: PDF documents, presentations, on-demand video, live streaming, interactive quizzes, and virtual labs. The content delivery architecture must guarantee low latency, per-tenant isolation, and granular access control.
Multi-Tenant Storage Strategy
The storage pattern choice mirrors the database choice: more isolation means more cost. The most common approach for SaaS LMS uses a single S3 bucket with prefix-based isolation:
# S3 organization pattern for multi-tenant LMS
# Bucket: lms-content-production
# Course content
s3://lms-content/{tenant_id}/courses/{course_id}/videos/lecture-01.mp4
s3://lms-content/{tenant_id}/courses/{course_id}/documents/syllabus.pdf
s3://lms-content/{tenant_id}/courses/{course_id}/images/cover.webp
# Student submissions
s3://lms-content/{tenant_id}/submissions/{assessment_id}/{user_id}/file.pdf
# Tenant assets (logo, branding)
s3://lms-content/{tenant_id}/branding/logo.svg
s3://lms-content/{tenant_id}/branding/theme.json
# Bucket policy limits access by prefix = tenant_id
# Signed URLs with 1-hour expiry for content access
CDN Architecture for Educational Video
Video represents 70-80% of a modern LMS's traffic. The video delivery architecture requires:
- Adaptive transcoding: Each video is encoded in multiple resolutions (360p, 720p, 1080p) with HLS/DASH for adaptive bitrate streaming.
- CDN with token authentication: CloudFront or Cloudflare with signed URLs to prevent unauthorized access to paid content.
- Per-tenant edge caching: Content from larger tenants is pre-loaded to edge PoPs to reduce TTFB.
- Dynamic watermarking: Invisible overlay with User ID to trace content leaks.
Video Pipeline Architecture
The complete flow for a video uploaded by an instructor:
- Upload: Presigned URL for direct S3 upload (bypasses application server)
- S3 Event: Lambda/Cloud Function trigger on
s3:ObjectCreated - Transcoding: AWS MediaConvert or FFmpeg on ECS/Cloud Run to generate multi-bitrate HLS
- Metadata: Duration, thumbnail, dimensions written to the LMS database
- CDN Invalidation: Cache warming for the owning tenant
- Notification: Event published to notify the instructor via WebSocket
Event-Driven Architecture for Real-Time Features
A modern LMS requires many asynchronous and real-time features: push notifications when an instructor posts a grade, live leaderboard updates, real-time progress tracking on the instructor dashboard, automatic reminders for upcoming deadlines. These requirements demand an event-driven architecture.
Domain Events for LMS
The first step is identifying the significant domain events of the platform. Each event is an immutable fact describing something that happened in the system.
// Immutable base event
interface LmsDomainEvent {
readonly eventId: string;
readonly eventType: string;
readonly tenantId: string;
readonly userId: string;
readonly timestamp: string; // ISO 8601
readonly version: number; // Schema versioning
}
// === ENROLLMENT EVENTS ===
interface StudentEnrolledEvent extends LmsDomainEvent {
readonly eventType: 'student.enrolled';
readonly payload: {
readonly courseId: string;
readonly courseName: string;
readonly enrollmentId: string;
};
}
// === PROGRESS EVENTS ===
interface LessonCompletedEvent extends LmsDomainEvent {
readonly eventType: 'lesson.completed';
readonly payload: {
readonly courseId: string;
readonly moduleId: string;
readonly lessonId: string;
readonly progressPercent: number; // 0-100 for the course
};
}
// === ASSESSMENT EVENTS ===
interface AssessmentSubmittedEvent extends LmsDomainEvent {
readonly eventType: 'assessment.submitted';
readonly payload: {
readonly assessmentId: string;
readonly submissionId: string;
readonly courseId: string;
readonly attemptNumber: number;
};
}
interface AssessmentGradedEvent extends LmsDomainEvent {
readonly eventType: 'assessment.graded';
readonly payload: {
readonly submissionId: string;
readonly assessmentId: string;
readonly score: number;
readonly maxScore: number;
readonly passed: boolean;
};
}
// === COURSE LIFECYCLE EVENTS ===
interface CoursePublishedEvent extends LmsDomainEvent {
readonly eventType: 'course.published';
readonly payload: {
readonly courseId: string;
readonly courseName: string;
readonly instructorId: string;
readonly moduleCount: number;
readonly lessonCount: number;
};
}
// Event bus with tenant-based partitioning
class LmsEventBus {
constructor(private readonly kafka: KafkaProducer) {}
async publish(event: LmsDomainEvent): Promise<void> {
await this.kafka.send({
topic: `lms.events.






