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 - 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.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
courseAiConfig.controllerPer-course AI provider configuration
conversation.controllerAI chat conversation management
message.controllerChat message CRUD operations
chatAnalytics.controllerAI chat usage analytics
toolPermission.controllerAI tool permission management
signedRequest.controllerSigned URL generation
storage.controllerDirect storage operations
stream.controllerServer-sent events streaming
tasks.controllerBackground task management
wellknown.controllerOAuth/.well-known endpoints
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;
      }
    }
  }
}

AI Providers

The backend supports multiple AI providers through a provider-agnostic interface (IAiProviderService):

ProviderService ClassDefault ModelUse Case
OpenAIOpenAiServicegpt-4.1-miniDefault, general-purpose AI
AnthropicAnthropicServiceclaude-sonnet-4-20250514Claude for complex reasoning
GoogleGoogleAiServicegemini-1.5-proGemini for multimodal
CustomCustomBackendServiceConfigurableSelf-hosted LLMs (Ollama, LM Studio)
MockMockAiServiceN/ATesting 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:

CategoryAvailable Tools
Calendarcalendar_create_event, calendar_get_events
Quizquiz_get_attempts, quiz_get_questions
Coursecourse_get_modules, course_get_chapters
Discussiondiscussion_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:

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