Skip to content

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.json

Architecture 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:

ControllerResponsibility
auth.controllerAuthentication (signup, login, token refresh)
course.controllerCourse CRUD operations
module.controllerModule management within courses
chapter.controllerChapter/section management
quiz.controllerQuiz creation, attempts, grading
discussion.controllerDiscussion threads and comments
courseFile.controllerFile uploads/downloads with storage
membership.controllerCourse membership management
role.controllerRole and permission management
calendar.controllerCourse calendar events
contentAnalytics.controllerContent engagement analytics
userAnalytics.controllerUser behavior analytics
openai.controllerAI chat integration
health.controllerHealth 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_DISCUSSIONS
  • EDIT_DISCUSSIONS
  • DELETE_DISCUSSIONS
  • CREATE_USERS
  • EDIT_USERS
  • DELETE_USERS
  • UPLOAD_FILES
  • EDIT_FILES
  • DELETE_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

  1. Use TSOA decorators for all controllers
  2. Implement services for business logic
  3. Use dependency injection for testability
  4. Throw custom errors for consistent error handling
  5. Validate inputs at controller level
  6. Use transactions for multi-step operations
  7. Log appropriately with Pino
  8. Document with JSDoc for OpenAPI generation