ExisJS

Project Structure

ExisJS is a minimal, opinionated core: file-system routing, dependency injection, validation, sanitization, and a request pipeline. Everything else — databases, auth, caching, queues, websockets — is an official @exisjs/* package you add only when you need it. Nothing in this structure requires them.

ExisJS enforces a strict src/ directory convention. All application code must live inside src/.


The Default Structure

my-backend/├── .exis/                    # Internal framework cache and build artifacts├── src/│   ├── http/│   │   ├── server.ts         # Application entry point│   │   ├── route.ts          # Root routes (e.g. GET /api)│   │   ├── boundary.ts       # Root boundary (config + request pipeline)│   │   ├── users/│   │   │   ├── route.ts      # User endpoints│   │   │   └── schema.ts     # Validation schemas (tex) — mandatory alongside route.ts│   │   ├── posts/│   │   │   ├── route.ts│   │   │   ├── schema.ts│   │   │   └── service.ts    # Business logic / data access, DI-injectable│   │   └── admin/│   │       ├── boundary.ts   # Applies to /admin/* and everything nested│   │       ├── route.ts│   │       └── posts/│   │           ├── route.ts│   │           ├── schema.ts│   │           └── service.ts│   ├── models/                # Free-form. Populated once you add a data package.│   ├── services/               # Free-form. Shared business logic.│   ├── dto/                    # Free-form. Request/response types.│   └── config/env.ts                  # Type-safe environment schema (tex)├── tests/├── .env├── exis.config.ts├── package.json└── tsconfig.json
DEFAULT

src/jobs/ is not part of the core structure. It only appears once you install @exisjs/queue — see that package's docs for its convention.


File Roles (identical meaning in both paradigms)

A file's name declares its role. Functional vs. class-based (OOP) only changes syntax, never responsibility.

FileRoleNever contains
route.tsBinds a URL + method to a handler.Business logic, validation logic
schema.tstex validation schemas, imported by route.ts. Mandatory for every route.ts that accepts input.Handlers, routing
service.tsBusiness logic and data access. DI-injectable.Routing, response shaping
boundary.tsFolder-scoped config + request pipeline for everything in this folder and below.

src/http/**/route.ts

Any route.ts (or .js) under src/http/ maps to a URL from its folder path:

  • src/http/route.ts/api
  • src/http/users/route.ts/api/users
  • src/http/users/[id]/route.ts/api/users/:id
  • src/http/(internal)/debug/route.ts/api/debug (group folders are ignored)

Functional

src/http/users/route.ts
import { controller, route } from "exisjs/router";import { UserParamsSchema } from "./schema";import { fetchUser } from "./service";
export default controller({  getUser: route.get("/users/:id", {    params: UserParamsSchema,    async handle({ params }) {      return fetchUser(params.id);    },  }),});

Class-based (OOP)

The decorated class is the route file — no separate controller file:

src/http/users/route.ts
import { Controller, Get, Param } from "exisjs/decorators";import { UserParamsSchema } from "./schema";import { UserService } from "./service";
@Controller("/users")export default class UserRoute {  constructor(private userService: UserService) {}
  @Get("/:id", UserParamsSchema)  async getUser(@Param("id") id: string) {    return this.userService.fetchUser(id);  }}
DEFAULT

[!IMPORTANT] Choose one paradigm — Functional or OOP — for the entire project. Mixing them causes a fatal error at startup.


src/http/**/schema.ts

src/http/users/schema.ts
import { tex } from "exisjs/validator";
export const UserParamsSchema = tex.object({ id: tex.string() });

tex is the only validation engine. Schemas here are also the source for OpenAPI generation once @exisjs/swagger is installed.


src/http/**/service.ts

Plain functions (functional) or an @Injectable() class (OOP) — the data/business layer, identical concept in both paradigms:

src/http/users/service.ts
// Functionalexport async function fetchUser(id: string) {  // ...}
src/http/users/service.ts
// OOPimport { Injectable } from "exisjs/decorators";
@Injectable()export class UserService {  async fetchUser(id: string) {    // ...  }}

src/http/**/boundary.ts

A boundary.ts applies to every route in its folder and all subfolders. It has two independent halves:

  1. Config — pure data, via defineBoundary() (functional) or @Boundary() (OOP argument). CORS, headers, DI providers, excluded paths.
  2. Pipeline — real code, auto-detected by function signature. No imports, no manual registration required.
SignatureDetected asRuns
(req, res, next)A chain stepIn file/declaration order, before the route
(ctx, next) — default export (functional) or a method named handle (OOP)The wrapperAround all chain steps and the nested route/boundary

Functional

src/http/admin/boundary.ts
import { defineBoundary } from "exisjs/router";import type { Request, Response, Next, BoundaryContext } from "exisjs/router";
export const config = defineBoundary({  cors: { origin: ["https://myapp.com"], credentials: true },  headers: { "X-Powered-By": "ExisJS" },  exclude: [{ path: "/health", method: "GET" }],});
export function auth(req: Request, res: Response, next: Next) {  if (!req.headers["x-api-key"]) throw new UnauthorizedError("Missing API key");  next();}
export default async function (ctx: BoundaryContext, next: Next) {  try {    return { success: true, data: await next() };  } catch (err) {    return { success: false, error: { message: (err as Error).message } };  }}

Class-based (OOP)

src/http/admin/boundary.ts
import { Boundary } from "exisjs/decorators";import type { Request, Response, Next, BoundaryContext } from "exisjs/router";
@Boundary({  cors: { origin: ["https://myapp.com"], credentials: true },  headers: { "X-Powered-By": "ExisJS" },})export default class AdminBoundary {  auth(req: Request, res: Response, next: Next) {    if (!req.headers["x-api-key"])      throw new UnauthorizedError("Missing API key");    next();  }
  async handle(ctx: BoundaryContext, next: Next) {    try {      return { success: true, data: await next() };    } catch (err) {      return { success: false, error: { message: (err as Error).message } };    }  }}

A nested folder's boundary.ts always runs inside its parent's next() — pipeline depth mirrors folder depth automatically.


exis.config.ts

Global infrastructure: port, SSL, CORS defaults, security headers, and official package registration.

exis.config.ts
import { defineConfig } from "exisjs";
export default defineConfig({  port: 3000,  cors: { origin: "*" },  // plugins: [] — official packages (@exisjs/database, @exisjs/auth, etc.)  // are registered here once installed. None are required by default.});

src/env.ts

src/env.ts
import { tex } from "exisjs/validator";
export const env = tex  .object({    PORT: tex.number({ coerce: true, optional: true }),    NODE_ENV: tex.enum(["development", "production", "test"]),  })  .parse(process.env);

Boot fails immediately if a required variable is missing.


.exis/

Internal build cache — compiled route manifest, used for O(1) boot in production. Add to .gitignore.


Custom Directories

Only src/http/ (routing) and, if @exisjs/queue is installed, src/jobs/ are enforced. Everything else — src/models/, src/services/, src/dto/ — is free-form.


The ExisJS CLI

  • exis dev — dev server with HMR
  • exis build — compile for production
  • exis start — run compiled output
  • exis routes — print every compiled endpoint
  • exis generate <type> — scaffold a route, boundary, service, or schema
  • exis add <package> — install and wire an official package (e.g. exis add database)