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.jsonsrc/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.
| File | Role | Never contains |
|---|---|---|
route.ts | Binds a URL + method to a handler. | Business logic, validation logic |
schema.ts | tex validation schemas, imported by route.ts. Mandatory for every route.ts that accepts input. | Handlers, routing |
service.ts | Business logic and data access. DI-injectable. | Routing, response shaping |
boundary.ts | Folder-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→/apisrc/http/users/route.ts→/api/userssrc/http/users/[id]/route.ts→/api/users/:idsrc/http/(internal)/debug/route.ts→/api/debug(group folders are ignored)
Functional
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:
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); }}[!IMPORTANT] Choose one paradigm — Functional or OOP — for the entire project. Mixing them causes a fatal error at startup.
src/http/**/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:
// Functionalexport async function fetchUser(id: string) { // ...}// 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:
- Config — pure data, via
defineBoundary()(functional) or@Boundary()(OOP argument). CORS, headers, DI providers, excluded paths. - Pipeline — real code, auto-detected by function signature. No imports, no manual registration required.
| Signature | Detected as | Runs |
|---|---|---|
(req, res, next) | A chain step | In file/declaration order, before the route |
(ctx, next) — default export (functional) or a method named handle (OOP) | The wrapper | Around all chain steps and the nested route/boundary |
Functional
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)
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.
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
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 HMRexis build— compile for productionexis start— run compiled outputexis routes— print every compiled endpointexis generate <type>— scaffold a route, boundary, service, or schemaexis add <package>— install and wire an official package (e.g.exis add database)