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 with Bun - JavaScript runtime
- 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
│ │ └── providers/
│ │ ├── firebase.service.ts
│ │ ├── appwrite.service.ts
│ │ └── openai.service.ts
│ │
│ ├── 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:
typescript
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 |
health.controller | Health checks and monitoring |
2. Service Layer
Services contain business logic and interact with the database.
Example Service:
typescript
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
typescript
// 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
typescript
// 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
typescript
// 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:
typescript
// 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:
typescript
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:
typescript
// 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
typescript
// 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
typescript
// 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
typescript
// 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;
}
}
}
}Permission System
Role-based access control (RBAC) implementation:
typescript
// 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):
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):
typescript
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
typescript
// 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
typescript
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
typescript
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