Files
claudecodeui/docs/backend/architecture.md

24 KiB

Backend Architecture

Goal

This document defines the target backend shape for the TypeScript refactor in server/src.

The main constraints are:

  • Keep the current API paths stable while the internals move out of server/index.js and server/routes/*.js.
  • Migrate one route family at a time instead of doing a big-bang rewrite.
  • Keep controllers thin and move business logic into services and repositories.
  • Keep transport concerns, provider adapters, and domain logic separate.

Current State

As of March 18, 2026:

  • server/src/bootstrap.ts starts the TypeScript backend entrypoint.
  • server/src/app.ts still bridges into server/src/runner.ts.
  • server/src/shared/* already contains reusable TypeScript building blocks such as:
    • shared/http/api-response.ts
    • shared/http/async-handler.ts
    • shared/http/error-handler.ts
    • shared/http/request-context.ts
    • shared/utils/app-error.ts
    • shared/database/repositories/*
  • server/src/modules/* mostly exist as placeholder folders.
  • The real runtime behavior still mostly lives in:
    • server/index.js
    • server/routes/*.js
  • docs/backend/endpoint-inventory.md is the migration checklist for the existing HTTP surface.
server/
  index.js                      # legacy runtime bridge during migration
  start.js                      # production entrypoint for compiled output
  src/
    bootstrap.ts                # starts the backend process
    app.ts                      # creates the app/server and registers modules
    runner.ts                   # transitional runtime while legacy code still exists
    config/
      load-env-vars.ts
      runtime.ts
    realtime/
      index.ts                  # attaches websocket handlers to the HTTP server
      chat.gateway.ts           # chat websocket behavior
      shell.gateway.ts          # shell websocket behavior
      events.ts                 # shared broadcast helpers
    shared/
      auth/
        authenticate-token.ts   # future TS auth middleware
        validate-api-key.ts     # future TS API-key middleware
      database/
        connection.ts
        init-db.ts
        migrations.ts
        schema.ts
        types.ts
        repositories/
          api-keys.ts
          app-config.ts
          credentials.ts
          session-names.ts
          users.ts
      docs/
        openapi.ts
      http/
        api-response.ts
        async-handler.ts
        error-handler.ts
        not-found-handler.ts
        request-context.ts
      input/
        parse-boolean.ts        # future shared query/body parsers
        parse-pagination.ts
      platform/
        index.ts
        path.ts
        runtime-platform.ts
        shell.ts
        stream.ts
        text.ts
        types.ts
      types/
        app.ts
        http.ts
      utils/
        app-error.ts
        logger.ts
    modules/
      auth/
        index.ts
        auth.routes.ts
        auth.controller.ts
        auth.service.ts
        auth.schemas.ts
        auth.types.ts
      cli-auth/
        index.ts
        cli-auth.routes.ts
        cli-auth.controller.ts
        cli-auth.service.ts
      user/
        index.ts
        user.routes.ts
        user.controller.ts
        user.service.ts
        user.schemas.ts
        user.types.ts
      settings/
        index.ts
        settings.routes.ts
        settings.controller.ts
        settings.service.ts
        settings.schemas.ts
      commands/
        index.ts
        commands.routes.ts
        commands.controller.ts
        commands.service.ts
      projects/
        index.ts
        projects.routes.ts
        projects.controller.ts
        projects.service.ts
        projects.schemas.ts
        workspace-path.policy.ts
      files/
        index.ts
        files.routes.ts
        files.controller.ts
        files.service.ts
        files.schemas.ts
      sessions/
        index.ts
        sessions.routes.ts
        sessions.controller.ts
        sessions.service.ts
        sessions.schemas.ts
      git/
        index.ts
        git.routes.ts
        git.controller.ts
        git.service.ts
      taskmaster/
        index.ts
        taskmaster.routes.ts
        taskmaster.controller.ts
        taskmaster.service.ts
      agent/
        index.ts
        agent.routes.ts
        agent.controller.ts
        agent.service.ts
      system/
        index.ts
        system.routes.ts
        system.controller.ts
        system.service.ts
        spa-fallback.ts
      providers/
        claude/
          index.ts
          claude.service.ts
          claude-session.service.ts
        codex/
          index.ts
          codex.routes.ts
          codex.controller.ts
          codex.service.ts
        cursor/
          index.ts
          cursor.routes.ts
          cursor.controller.ts
          cursor.service.ts
        gemini/
          index.ts
          gemini.routes.ts
          gemini.controller.ts
          gemini.service.ts
        mcp/
          index.ts
          mcp.routes.ts
          mcp.controller.ts
          mcp.service.ts
        plugins/
          index.ts
          plugins.routes.ts
          plugins.controller.ts
          plugins.service.ts
    testing/
      fixtures/

Why This Shape Works For This Repo

  • You already have shared TypeScript HTTP/database utilities in server/src/shared.
  • The legacy route surface is already grouped by domain in server/routes/*.js.
  • The biggest remaining problem is not "what framework should I use", it is "where should each existing endpoint live and how do I migrate it without breaking paths".
  • A module-per-domain structure solves that without forcing a rewrite of all helpers at once.

Route Ownership

Keep the public paths stable. Only move the code behind them.

Module Mount path(s) Current source(s) Notes
auth /api/auth server/routes/auth.js Login, register, logout, auth status, current user
cli-auth /api/cli server/routes/cli-auth.js Provider CLI auth status endpoints
user /api/user server/routes/user.js Git identity and onboarding state
settings /api/settings server/routes/settings.js API keys, credentials, notification preferences, push setup
commands /api/commands server/routes/commands.js Slash command list, load, execute
projects /api/projects, /api/browse-filesystem, /api/create-folder server/routes/projects.js, server/index.js Workspace discovery and workspace creation helpers
files /api/projects/:projectName/file, /api/projects/:projectName/files server/index.js File tree, read/write, upload, rename, delete
sessions /api/projects/:projectName/sessions, /api/sessions/:sessionId/rename, /api/search/conversations server/index.js, provider route files Project/provider session history
git /api/git server/routes/git.js Repo operations and git automation
taskmaster /api/taskmaster server/routes/taskmaster.js TaskMaster setup, PRDs, tasks, parsing
agent /api/agent server/routes/agent.js External agent orchestration
system /health, /api/system, /api/transcribe, SPA fallback server/index.js Health, app update, transcription, non-API fallback. Register the SPA fallback last.
providers/codex /api/codex server/routes/codex.js Config, MCP-adjacent behavior, Codex sessions
providers/cursor /api/cursor server/routes/cursor.js Config, MCP, Cursor sessions
providers/gemini /api/gemini server/routes/gemini.js Gemini session endpoints
providers/mcp /api/mcp, /api/mcp-utils server/routes/mcp.js, server/routes/mcp-utils.js Shared MCP HTTP endpoints
providers/plugins /api/plugins server/routes/plugins.js Plugin install, enable/disable, assets
providers/claude no standalone HTTP prefix yet server/index.js, server/claude-sdk.js Service/adapters used by chat and agent flows
realtime /ws, /shell server/index.js Websocket transport, not HTTP routes

Module Convention

Every feature module should follow the same internal shape:

modules/user/
  index.ts            # registers the module on the app
  user.routes.ts      # express.Router + middleware order
  user.controller.ts  # request/response mapping only
  user.service.ts     # business rules and orchestration
  user.schemas.ts     # input parsing and validation
  user.types.ts       # module-local types only

Responsibilities

  • index.ts Owns the mount path. app.ts should not know route internals.

  • *.routes.ts Declares routes, middleware order, and child-router mounting. It should not contain SQL, filesystem logic, or long validation blocks.

  • *.controller.ts Reads req, calls schema parsers and services, then writes the response. It should stay thin.

  • *.service.ts Owns business logic. It can call repositories, filesystem helpers, platform helpers, provider adapters, and child processes.

  • *.schemas.ts Validates/parses request data close to the module. Throw AppError with 400 status for bad input.

  • *.types.ts Holds module-local types that do not belong in shared/types.

Application Setup

The current server/src/app.ts still starts a transitional runner. Once the HTTP runtime moves into server/src, the shape should look like this:

import cors from 'cors';
import express from 'express';
import http from 'http';

import { registerAgentModule } from '@/modules/agent/index.js';
import { registerAuthModule } from '@/modules/auth/index.js';
import { registerCliAuthModule } from '@/modules/cli-auth/index.js';
import { registerCommandsModule } from '@/modules/commands/index.js';
import { registerFilesModule } from '@/modules/files/index.js';
import { registerGitModule } from '@/modules/git/index.js';
import { registerProjectsModule } from '@/modules/projects/index.js';
import { registerSessionsModule } from '@/modules/sessions/index.js';
import { registerSettingsModule } from '@/modules/settings/index.js';
import { registerSystemModule } from '@/modules/system/index.js';
import { registerTaskmasterModule } from '@/modules/taskmaster/index.js';
import { registerUserModule } from '@/modules/user/index.js';
import { registerCodexProviderModule } from '@/modules/providers/codex/index.js';
import { registerCursorProviderModule } from '@/modules/providers/cursor/index.js';
import { registerGeminiProviderModule } from '@/modules/providers/gemini/index.js';
import { registerMcpProviderModule } from '@/modules/providers/mcp/index.js';
import { registerPluginsProviderModule } from '@/modules/providers/plugins/index.js';
import { registerSpaFallback } from '@/modules/system/spa-fallback.js';
import { errorHandler } from '@/shared/http/error-handler.js';
import { notFoundHandler } from '@/shared/http/not-found-handler.js';
import { requestContextMiddleware } from '@/shared/http/request-context.js';
import { attachRealtimeHandlers } from '@/realtime/index.js';

export function createHttpRuntime() {
  const app = express();
  const server = http.createServer(app);

  // Global middleware should be registered once, before feature modules.
  app.use(cors());
  app.use(express.json({ limit: '10mb' }));
  app.use(express.urlencoded({ extended: true }));
  app.use(requestContextMiddleware);

  // Keep health/system API routes early so diagnostics still work
  // even if a later feature module throws during registration.
  registerSystemModule(app);

  // Public/auth routes first.
  registerAuthModule(app);

  // Authenticated feature modules after auth.
  registerCliAuthModule(app);
  registerUserModule(app);
  registerSettingsModule(app);
  registerCommandsModule(app);
  registerProjectsModule(app);
  registerFilesModule(app);
  registerSessionsModule(app);
  registerGitModule(app);
  registerTaskmasterModule(app);
  registerAgentModule(app);
  registerCodexProviderModule(app);
  registerCursorProviderModule(app);
  registerGeminiProviderModule(app);
  registerMcpProviderModule(app);
  registerPluginsProviderModule(app);

  // Websocket setup should live outside HTTP controllers.
  attachRealtimeHandlers(server, app);

  // The React fallback must be last or it will swallow real API routes.
  registerSpaFallback(app);

  app.use(notFoundHandler);
  app.use(errorHandler);

  return { app, server };
}

Example Module: user

This is a good first migration candidate because the endpoints are small, the domain is clear, and you already have a typed user repository in server/src/shared/database/repositories/users.ts.

Folder

server/src/modules/user/
  index.ts
  user.routes.ts
  user.controller.ts
  user.service.ts
  user.schemas.ts
  user.types.ts

index.ts

import type { Express } from 'express';

import { createUserRouter } from './user.routes.js';

export function registerUserModule(app: Express): void {
  // Keep the mount path in one place.
  // This makes it easy to see who owns `/api/user`.
  app.use('/api/user', createUserRouter());
}

user.routes.ts

import { Router } from 'express';

import { asyncHandler } from '@/shared/http/async-handler.js';
// Day 1: reuse the existing JS middleware while auth is still being migrated.
import { authenticateToken } from '../../../middleware/auth.js';

import {
  completeOnboardingController,
  getGitConfigController,
  getOnboardingStatusController,
  updateGitConfigController,
} from './user.controller.js';

export function createUserRouter(): Router {
  const router = Router();

  // Routes only describe the HTTP surface and middleware order.
  router.get('/git-config', authenticateToken, asyncHandler(getGitConfigController));
  router.post('/git-config', authenticateToken, asyncHandler(updateGitConfigController));
  router.get('/onboarding-status', authenticateToken, asyncHandler(getOnboardingStatusController));
  router.post('/complete-onboarding', authenticateToken, asyncHandler(completeOnboardingController));

  return router;
}

user.schemas.ts

import { AppError } from '@/shared/utils/app-error.js';

export type UpdateGitConfigInput = {
  gitName: string;
  gitEmail: string;
};

export function parseUpdateGitConfigInput(body: unknown): UpdateGitConfigInput {
  const value = body as Partial<UpdateGitConfigInput> | null;

  // Keep input validation close to the module so controllers stay thin.
  if (!value?.gitName || !value?.gitEmail) {
    throw new AppError('Git name and email are required', {
      code: 'VALIDATION_ERROR',
      statusCode: 400,
    });
  }

  const gitName = value.gitName.trim();
  const gitEmail = value.gitEmail.trim();

  if (!gitName) {
    throw new AppError('Git name is required', {
      code: 'VALIDATION_ERROR',
      statusCode: 400,
    });
  }

  if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(gitEmail)) {
    throw new AppError('Invalid email format', {
      code: 'VALIDATION_ERROR',
      statusCode: 400,
    });
  }

  return {
    gitName,
    gitEmail,
  };
}

user.service.ts

import { userDb } from '@/shared/database/repositories/users.js';
import { AppError } from '@/shared/utils/app-error.js';

import type { UpdateGitConfigInput } from './user.schemas.js';

export const userService = {
  getGitConfig(userId: number) {
    const gitConfig = userDb.getGitConfig(userId);

    return {
      gitName: gitConfig?.git_name ?? null,
      gitEmail: gitConfig?.git_email ?? null,
    };
  },

  updateGitConfig(userId: number, input: UpdateGitConfigInput) {
    if (!userDb.getUserById(userId)) {
      throw new AppError('User not found', {
        code: 'USER_NOT_FOUND',
        statusCode: 404,
      });
    }

    // Services own business rules and side effects.
    // If you later re-add `git config --global`, it belongs here.
    userDb.updateGitConfig(userId, input.gitName, input.gitEmail);

    return {
      gitName: input.gitName,
      gitEmail: input.gitEmail,
    };
  },

  getOnboardingStatus(userId: number) {
    return {
      hasCompletedOnboarding: userDb.hasCompletedOnboarding(userId),
    };
  },

  completeOnboarding(userId: number) {
    userDb.completeOnboarding(userId);

    return {
      message: 'Onboarding completed successfully',
    };
  },
};

user.controller.ts

import type { Response } from 'express';

import {
  createApiMeta,
  createApiSuccessResponse,
} from '@/shared/http/api-response.js';
import { getRequestContext } from '@/shared/http/request-context.js';
import type { AuthenticatedRequest } from '@/shared/types/http.js';
import { AppError } from '@/shared/utils/app-error.js';

import { parseUpdateGitConfigInput } from './user.schemas.js';
import { userService } from './user.service.js';

function getUserId(req: AuthenticatedRequest): number {
  const userId = Number(req.user?.id);

  if (!Number.isFinite(userId)) {
    throw new AppError('Authenticated user is missing', {
      code: 'UNAUTHENTICATED',
      statusCode: 401,
    });
  }

  return userId;
}

function getMeta(req: AuthenticatedRequest) {
  const context = getRequestContext(req);
  return createApiMeta(context?.requestId, context?.startedAt);
}

export async function getGitConfigController(
  req: AuthenticatedRequest,
  res: Response
): Promise<void> {
  const data = userService.getGitConfig(getUserId(req));
  res.json(createApiSuccessResponse(data, getMeta(req)));
}

export async function updateGitConfigController(
  req: AuthenticatedRequest,
  res: Response
): Promise<void> {
  const input = parseUpdateGitConfigInput(req.body);
  const data = userService.updateGitConfig(getUserId(req), input);
  res.json(createApiSuccessResponse(data, getMeta(req)));
}

export async function getOnboardingStatusController(
  req: AuthenticatedRequest,
  res: Response
): Promise<void> {
  const data = userService.getOnboardingStatus(getUserId(req));
  res.json(createApiSuccessResponse(data, getMeta(req)));
}

export async function completeOnboardingController(
  req: AuthenticatedRequest,
  res: Response
): Promise<void> {
  const data = userService.completeOnboarding(getUserId(req));
  res.json(createApiSuccessResponse(data, getMeta(req)));
}

Nested Route Example: files

files is the best example of why route ownership and mount paths matter. The files module should stay separate from projects, but its routes still need access to :projectName.

files/index.ts

import type { Express } from 'express';

import { createFilesRouter } from './files.routes.js';

export function registerFilesModule(app: Express): void {
  // The files module owns project-scoped file operations.
  app.use('/api/projects/:projectName', createFilesRouter());
}

files.routes.ts

import { Router } from 'express';

import { asyncHandler } from '@/shared/http/async-handler.js';
import { authenticateToken } from '../../../middleware/auth.js';

import {
  deleteFilesController,
  getFileController,
  getFilesController,
  putFileController,
} from './files.controller.js';

export function createFilesRouter(): Router {
  // `mergeParams: true` lets this router read `req.params.projectName`
  // from the mount path declared in `index.ts`.
  const router = Router({ mergeParams: true });

  router.get('/file', authenticateToken, asyncHandler(getFileController));
  router.put('/file', authenticateToken, asyncHandler(putFileController));
  router.get('/files', authenticateToken, asyncHandler(getFilesController));
  router.delete('/files', authenticateToken, asyncHandler(deleteFilesController));

  return router;
}

Why This Separation Is Useful

  • projects decides what a project/workspace is.
  • files only operates inside a resolved project.
  • sessions only deals with chat/session history.
  • This prevents one giant projects.service.ts from becoming the new monolith.

Provider Module Pattern

Provider modules should follow the same structure, but they usually own adapter logic as well as HTTP routes.

Example shape for providers/codex:

modules/providers/codex/
  index.ts
  codex.routes.ts
  codex.controller.ts
  codex.service.ts
  codex.types.ts
  codex-mcp.service.ts
  codex-sessions.service.ts

Use this rule:

  • If the code is specific to one provider, keep it inside that provider folder.
  • If the code is shared across multiple providers, move it into shared/ or providers/mcp/.
  • If the code is an external-facing orchestration endpoint that can call different providers, keep it in modules/agent/.

Realtime Structure

Do not leave websocket logic inside app.ts or feature controllers.

Use a dedicated server/src/realtime/ area:

server/src/realtime/
  index.ts
  chat.gateway.ts
  shell.gateway.ts
  events.ts

Suggested responsibilities:

  • index.ts Attaches websocket handlers to the HTTP server.

  • chat.gateway.ts Owns /ws message handling, provider session streaming, reconnect behavior, and approval flows.

  • shell.gateway.ts Owns /shell PTY session lifecycle, resize events, and output streaming.

  • events.ts Owns broadcast helpers used by HTTP modules such as TaskMaster or project updates.

Boundary Rules

  • app.ts wires modules together. It should not contain feature logic.
  • bootstrap.ts starts the process. It should not know route details.
  • routes files should not contain SQL, filesystem logic, provider process spawning, or long validation blocks.
  • controllers should not import better-sqlite3, fs, node-pty, or raw provider SDKs.
  • services own business rules, orchestration, and side effects.
  • repositories own SQL only.
  • shared/* is only for code used by two or more modules.
  • providers/* is for provider-specific behavior.
  • agent is orchestration above provider modules.
  • realtime/* owns websocket transport, not HTTP modules.
  • system owns health/update/fallback endpoints, not project/file/session behavior.

Migration Order

This order keeps risk low:

  1. auth, cli-auth, user Small route files, easy to validate, minimal path behavior.

  2. settings, commands Still mostly isolated and already grouped in dedicated route files.

  3. projects, files, sessions These are more coupled and require clear boundaries around projectName.

  4. git, taskmaster Heavier side effects and more external process behavior.

  5. providers/* Each provider can move independently once shared platform helpers are stable.

  6. agent Migrate last because it orchestrates several other pieces.

  7. realtime/* Move websocket logic after the supporting services are already modular.

Day 1 Migration Rules

  • Keep path compatibility first.
  • Move one route family at a time.
  • It is acceptable to import legacy JS middleware or helpers from a new TS module temporarily.
  • When a route family is migrated:
    • register the new TS module in app.ts
    • remove the old route registration from server/index.js
    • keep the response shape unchanged unless you intentionally version it
  • Prefer moving business logic into services first, then move transport code.

Practical Setup Checklist

When you create a new module, use this checklist:

  1. Add the folder under server/src/modules/<feature>/.
  2. Create index.ts, *.routes.ts, *.controller.ts, *.service.ts, and *.schemas.ts.
  3. Keep the mount path inside index.ts.
  4. Reuse asyncHandler, AppError, createApiSuccessResponse, and requestContextMiddleware.
  5. Reuse typed repositories from server/src/shared/database/repositories/* whenever possible.
  6. Register the module in server/src/app.ts.
  7. Move one legacy route at a time and verify the response shape still matches the frontend.

Package Script Notes

These scripts already match the refactor workflow:

  • npm run server:dev Use for backend-only refactor work in server/src.

  • npm run typecheck:server Run after every backend module migration.

  • npm run test:server Use for shared platform tests and expand it as new TS modules gain tests.

  • npm run server:build Confirms the refactored backend compiles into server/dist.

  • npm run verify:server Best validation command before merging backend refactor work.

Summary

The simplest stable rule set for this refactor is:

  • keep API paths the same
  • move code into domain modules
  • keep routes thin
  • keep business logic in services
  • keep SQL in repositories
  • keep provider code inside provider folders
  • keep websocket code outside HTTP modules

If you follow that consistently, server/src will become the real backend instead of a second monolith with TypeScript syntax.