Backend Architecture
The backend is an Express.js application with TypeORM, TSOA for OpenAPI generation, and comprehensive business logic for the LMS platform.
Technology Stack
- Node.js - JavaScript runtime (pnpm package manager)
- Express 5 - Web framework
- TypeORM 0.3.28 - Object-Relational Mapping
- TSOA - TypeScript OpenAPI generator
- tsyringe - Dependency injection container
- PostgreSQL - Primary database
- JWT - JSON Web Tokens for authentication
- Argon2 - Password hashing
- Pino - Logging
- Multer - File upload handling
Project Structure
apps/client-backend/
├── src/
│ ├── controllers/ # API endpoint handlers (23 controllers)
│ │ ├── auth.controller.ts
│ │ ├── course.controller.ts
│ │ ├── quiz.controller.ts
│ │ └── ...
│ │
│ ├── services/ # Business logic (22 services)
│ │ ├── auth.service.ts
│ │ ├── course.service.ts
│ │ ├── quiz.service.ts
│ │ └── ...
│ │
│ ├── integrations/ # External service integrations
│ │ ├── contracts/
│ │ │ ├── IStorageService.ts
│ │ │ └── IAiProviderService.ts ← AI provider interface
│ │ ├── providers/
│ │ │ ├── storage/
│ │ │ │ ├── firebase.service.ts
│ │ │ │ └── appwrite.service.ts
│ │ │ ├── ai/
│ │ │ │ ├── openai.service.ts
│ │ │ │ ├── anthropic.service.ts
│ │ │ │ ├── google-ai.service.ts
│ │ │ │ ├── custom-backend.service.ts
│ │ │ │ └── mockai.service.ts
│ │ │ └── README.md
│ │ │
│ │ ├── dynamic/
│ │ │ └── courseAwareAiProvider.service.ts ← AI provider router
│ │ └── README.md
│ │
│ ├── gateways/ # Gateway services
│ │ └── cloudFunctions.gateway.ts
│ │
│ ├── internal/ # Internal utilities
│ │ ├── middleware/ # Express middleware
│ │ │ ├── authenticate.ts
│ │ │ ├── courseMember.ts
│ │ │ ├── errorHandler.ts
│ │ │ └── development.ts
│ │ ├── errors/ # Custom error classes
│ │ └── types.ts # Internal type definitions
│ │
│ ├── views/ # Email templates (if applicable)
│ │
│ ├── generate.ts # OpenAPI spec generation script
│ ├── server.ts # Express server setup
│ └── index.ts # Application entry point
│
├── lib/ # Generated files
│ ├── swagger.json # OpenAPI specification
│ └── routes.gen.ts # Generated routes
│
├── tsoa.json # TSOA configuration
├── tsconfig.json # TypeScript configuration
└── package.jsonArchitecture Layers
1. Controller Layer
Controllers handle HTTP requests and responses using TSOA decorators.
Example Controller:
import { Controller, Get, Post, Route, Body, Path, Security } from "tsoa";
import { injectable, inject } from "tsyringe";
import { CourseService } from "../services/course.service";
import type { Course } from "@repo/db";
@injectable()
@Route("api/courses")
export class CourseController extends Controller {
constructor(@inject("CourseService") private courseService: CourseService) {
super();
}
/**
* Get all courses the authenticated user has access to
*/
@Security("jwt")
@Get()
public async getCourses(
@Request() req: AuthenticatedRequest,
): Promise<Course[]> {
return this.courseService.getUserCourses(req.user.id);
}
/**
* Get a specific course by ID
*/
@Security("jwt")
@Get("{id}")
public async getCourse(
@Path() id: number,
@Request() req: AuthenticatedRequest,
): Promise<Course> {
return this.courseService.getCourse(id, req.user.id);
}
/**
* Create a new course
*/
@Security("jwt")
@Post()
public async createCourse(
@Body() body: CreateCourseRequest,
@Request() req: AuthenticatedRequest,
): Promise<Course> {
return this.courseService.createCourse(body, req.user.id);
}
}Key Controllers:
| Controller | Responsibility |
|---|---|
auth.controller | Authentication (signup, login, token refresh) |
course.controller | Course CRUD operations |
module.controller | Module management within courses |
chapter.controller | Chapter/section management |
quiz.controller | Quiz creation, attempts, grading |
discussion.controller | Discussion threads and comments |
courseFile.controller | File uploads/downloads with storage |
membership.controller | Course membership management |
role.controller | Role and permission management |
calendar.controller | Course calendar events |
contentAnalytics.controller | Content engagement analytics |
userAnalytics.controller | User behavior analytics |
openai.controller | AI chat integration |
courseAiConfig.controller | Per-course AI provider configuration |
conversation.controller | AI chat conversation management |
message.controller | Chat message CRUD operations |
chatAnalytics.controller | AI chat usage analytics |
toolPermission.controller | AI tool permission management |
signedRequest.controller | Signed URL generation |
storage.controller | Direct storage operations |
stream.controller | Server-sent events streaming |
tasks.controller | Background task management |
wellknown.controller | OAuth/.well-known endpoints |
health.controller | Health checks and monitoring |
2. Service Layer
Services contain business logic and interact with the database.
Example Service:
import { singleton, inject } from "tsyringe";
import { DataSource } from "typeorm";
import { Course } from "@repo/db";
import { NotFoundError, ForbiddenError } from "../internal/errors";
@singleton()
export class CourseService {
constructor(@inject("DataSource") private dataSource: DataSource) {}
async getUserCourses(userId: number): Promise<Course[]> {
const courseRepo = this.dataSource.getRepository(Course);
return courseRepo
.createQueryBuilder("course")
.innerJoin("course.memberships", "membership")
.where("membership.userId = :userId", { userId })
.getMany();
}
async getCourse(courseId: number, userId: number): Promise<Course> {
const courseRepo = this.dataSource.getRepository(Course);
const course = await courseRepo.findOne({
where: { id: courseId },
relations: ["memberships", "modules"],
});
if (!course) {
throw new NotFoundError("Course not found");
}
// Check if user has access
const hasAccess = course.memberships.some((m) => m.userId === userId);
if (!hasAccess) {
throw new ForbiddenError("You do not have access to this course");
}
return course;
}
async createCourse(
data: CreateCourseRequest,
userId: number,
): Promise<Course> {
const courseRepo = this.dataSource.getRepository(Course);
const membershipRepo = this.dataSource.getRepository(Membership);
const course = courseRepo.create({
name: data.name,
code: data.code,
description: data.description,
});
await courseRepo.save(course);
// Create instructor membership
const membership = membershipRepo.create({
userId,
courseId: course.id,
roleId: INSTRUCTOR_ROLE_ID,
});
await membershipRepo.save(membership);
return course;
}
}Service Patterns:
- Decorated with
@singleton()for shared instances - Use constructor injection with
@inject() - Return entities or throw custom errors
- Handle transaction management when needed
3. Middleware
Authentication Middleware
// internal/middleware/authenticate.ts
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { UnauthorizedError } from "../errors";
export async function authenticate(
req: Request,
res: Response,
next: NextFunction,
) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
throw new UnauthorizedError("Missing or invalid authorization header");
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
req.user = decoded; // Attach user to request
next();
} catch (error) {
throw new UnauthorizedError("Invalid or expired token");
}
}Course Member Middleware
// internal/middleware/courseMember.ts
import { Request, Response, NextFunction } from "express";
import { ForbiddenError } from "../errors";
export function courseMember(paramName: string = "courseId") {
return async (req: Request, res: Response, next: NextFunction) => {
const courseId = Number(req.params[paramName]);
const userId = req.user.id;
const membership = await Membership.findOne({
where: { courseId, userId },
});
if (!membership) {
throw new ForbiddenError("You are not a member of this course");
}
req.membership = membership;
next();
};
}Error Handler
// internal/middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
import { BaseClientError } from "../errors/base";
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction,
) {
const timestamp = new Date().toISOString();
const path = req.path;
if (err instanceof BaseClientError) {
return res.status(err.status).json({
timestamp,
path,
status: err.status,
message: err.message,
details: err.details,
});
}
// Log unexpected errors
console.error("Unexpected error:", err);
return res.status(500).json({
timestamp,
path,
status: 500,
message: "Internal server error",
});
}4. Error Handling
Custom Error Hierarchy:
// internal/errors/base.ts
export abstract class BaseClientError extends Error {
abstract status: number;
details?: Record<string, unknown>;
constructor(message: string, details?: Record<string, unknown>) {
super(message);
this.details = details;
}
}
// internal/errors/400.ts
export class BadRequestError extends BaseClientError {
status = 400;
}
// internal/errors/401.ts
export class UnauthorizedError extends BaseClientError {
status = 401;
}
// internal/errors/403.ts
export class ForbiddenError extends BaseClientError {
status = 403;
}
// internal/errors/404.ts
export class NotFoundError extends BaseClientError {
status = 404;
}
// internal/errors/409.ts
export class ConflictError extends BaseClientError {
status = 409;
}
// internal/errors/422.ts
export class UnprocessableEntityError extends BaseClientError {
status = 422;
}Usage:
if (!user) {
throw new NotFoundError("User not found");
}
if (existingUser) {
throw new ConflictError("User already exists", { email: user.email });
}
if (!hasPermission) {
throw new ForbiddenError("You do not have permission to perform this action");
}Dependency Injection
The application uses tsyringe for dependency injection:
// Bootstrap DI container
import "reflect-metadata";
import { container } from "tsyringe";
import { DataSource } from "typeorm";
// Register DataSource
container.register("DataSource", {
useValue: dataSource,
});
// Register services (automatically via @singleton())
import "./services"; // All services are auto-registered
// Services are now injectable
@injectable()
export class CourseController extends Controller {
constructor(@inject("CourseService") private courseService: CourseService) {
super();
}
}Integration Services
Storage Service Interface
// integrations/contracts/IStorageService.ts
export interface IStorageService {
listAllFilesInStorage(prefix?: string): Promise<string[]>;
uploadToStorage(path: string, bytes: Buffer): Promise<void>;
renameInStorage(oldPath: string, newPath: string): Promise<void>;
getSignedUrlFromStorage(
path: string,
expiresInSeconds?: number,
): Promise<string>;
deleteFromStorage(path: string): Promise<void>;
}Firebase Implementation
// integrations/providers/firebase.service.ts
import { singleton } from "tsyringe";
import * as admin from "firebase-admin";
import { IStorageService } from "../contracts/IStorageService";
@singleton()
export class FirebaseStorageService implements IStorageService {
private bucket: admin.storage.Bucket;
constructor() {
const app = admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
privateKey: process.env.FIREBASE_PRIVATE_KEY,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
}),
});
this.bucket = app.storage().bucket();
}
async uploadToStorage(path: string, bytes: Buffer): Promise<void> {
await this.bucket.file(path).save(bytes);
}
async getSignedUrlFromStorage(
path: string,
expiresInSeconds: number = 3600,
): Promise<string> {
const [url] = await this.bucket.file(path).getSignedUrl({
action: "read",
expires: Date.now() + expiresInSeconds * 1000,
});
return url;
}
// Other methods...
}OpenAI Integration
// integrations/providers/openai.service.ts
import { singleton } from "tsyringe";
import OpenAI from "openai";
@singleton()
export class OpenAIService {
private client: OpenAI;
private model = "gpt-4.1-mini";
constructor() {
this.client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
}
async streamChatCompletion(messages: ChatMessage[]): AsyncIterable<string> {
const stream = await this.client.chat.completions.create({
model: this.model,
messages,
stream: true,
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
yield content;
}
}
}
}AI Providers
The backend supports multiple AI providers through a provider-agnostic interface (IAiProviderService):
| Provider | Service Class | Default Model | Use Case |
|---|---|---|---|
| OpenAI | OpenAiService | gpt-4.1-mini | Default, general-purpose AI |
| Anthropic | AnthropicService | claude-sonnet-4-20250514 | Claude for complex reasoning |
GoogleAiService | gemini-1.5-pro | Gemini for multimodal | |
| Custom | CustomBackendService | Configurable | Self-hosted LLMs (Ollama, LM Studio) |
| Mock | MockAiService | N/A | Testing and development |
Dynamic Provider Selection: The CourseAwareAiProviderService routes requests to the appropriate provider based on course configuration.
See the AI Architecture guide for detailed documentation.
AI Tool Registry
The AIToolRegistry enables AI agents to call backend services:
Key Features:
- Declarative Tool Definition: Tools are registered with metadata (name, description, parameters)
- Dynamic Execution: Handlers are invoked dynamically based on configuration
- Permission Control: Optional permission checking per tool
- Format Conversion: Converts to OpenAI function calling format
Supported Services:
| Category | Available Tools |
|---|---|
| Calendar | calendar_create_event, calendar_get_events |
| Quiz | quiz_get_attempts, quiz_get_questions |
| Course | course_get_modules, course_get_chapters |
| Discussion | discussion_search, discussion_get_posts |
For detailed information on AI tool registry and implementation, see the AI Architecture guide.
Permission System
Role-based access control (RBAC) implementation:
// services/permission.service.ts
@singleton()
export class PermissionService {
constructor(@inject("DataSource") private dataSource: DataSource) {}
async requirePermission(
userId: number,
courseId: number,
permissionType: PermissionType,
): Promise<void> {
const membershipRepo = this.dataSource.getRepository(Membership);
const membership = await membershipRepo.findOne({
where: { userId, courseId },
relations: ["role", "role.permissions"],
});
if (!membership) {
throw new ForbiddenError("Not a member of this course");
}
const hasPermission = membership.role.permissions.some(
(p) => p.type === permissionType,
);
if (!hasPermission) {
throw new ForbiddenError(
`Missing required permission: ${permissionType}`,
);
}
}
}Permission Types:
CREATE_DISCUSSIONSEDIT_DISCUSSIONSDELETE_DISCUSSIONSCREATE_USERSEDIT_USERSDELETE_USERSUPLOAD_FILESEDIT_FILESDELETE_FILES
OpenAPI Generation
TSOA automatically generates OpenAPI specification:
Configuration (tsoa.json):
{
"entryFile": "src/index.ts",
"noImplicitAdditionalProperties": "throw-on-extras",
"spec": {
"outputDirectory": "lib",
"specVersion": 3,
"securityDefinitions": {
"jwt": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
},
"routes": {
"routesDir": "lib",
"middleware": "express",
"iocModule": "./src/services/ioc"
}
}Generation Script (src/generate.ts):
import { watch } from "node:fs";
import tsoa from "tsoa";
const generate = () => tsoa.generateSpecAndRoutes({});
// Watch for changes and regenerate
const watcher = watch(import.meta.dir, { recursive: true }, generate);
generate();
process.on("SIGINT", () => {
watcher.close();
process.exit(0);
});Server Setup
// server.ts
import express from "express";
import cors from "cors";
import pinoHttp from "pino-http";
import { RegisterRoutes } from "../lib/routes.gen";
import { errorHandler } from "./internal/middleware/errorHandler";
export function createServer(dataSource: DataSource) {
const app = express();
// Middleware
app.use(
cors({
origin: process.env.CORS_ORIGIN?.split(",") || "*",
}),
);
app.use(express.json());
app.use(
pinoHttp({
/* logger config */
}),
);
// Register TSOA routes
RegisterRoutes(app);
// API documentation
app.use("/api-docs" /* Scalar UI setup */);
// Error handling
app.use(errorHandler);
return app;
}Database Patterns
Query Building
const courses = await courseRepo
.createQueryBuilder("course")
.leftJoinAndSelect("course.modules", "module")
.leftJoinAndSelect("module.chapters", "chapter")
.where("course.id = :id", { id: courseId })
.orderBy("module.order", "ASC")
.addOrderBy("chapter.order", "ASC")
.getOne();Transactions
await this.dataSource.transaction(async (manager) => {
const quiz = await manager.save(Quiz, quizData);
const questions = await manager.save(QuizQuestion, questionsData);
return { quiz, questions };
});Best Practices
- Use TSOA decorators for all controllers
- Implement services for business logic
- Use dependency injection for testability
- Throw custom errors for consistent error handling
- Validate inputs at controller level
- Use transactions for multi-step operations
- Log appropriately with Pino
- Document with JSDoc for OpenAPI generation
Related Documentation
- Type Safety Flow - Type generation process
- Database Package - Entity definitions
- API Package - Generated API client
- Testing - Backend testing strategies