Skip to content

Conventions ​

How files are named, where they live, and how modules talk to each other.

The one principle ​

Directory = meaning. Suffix = readability.

The framework discovers files by directory, never by filename. Every glob is extension-agnostic:

ts
discoverModuleFiles("**/routes/*.{ts,js}");
discoverModuleFiles("**/jobs/*.{ts,js}");
discoverModuleFiles("**/database/models/*.{ts,js}");
discoverModuleFiles("**/{schedules,console}/*.{ts,js}");

So the folder is what makes a file work. The .controller.ts / .service.ts suffix is a readability aid only — any filename inside the right folder is valid.

Don't switch to suffix-based discovery

Writing **\/*.model.ts instead of **/database/models/* would break every existing module. Directory stays king.

Directory table ​

All paths are relative to src/modules/<module>/.

KindDirectorySuffixExample
test__test__/*.test.tspost.test.ts
consoleconsole/*.tscleanup.ts
controllercontrollers/*.tspost.ts
databasedatabase/*.tspost.ts
modeldatabase/models/*.tspost.ts
seederdatabase/seeders/*.tspost.ts
helperhelpers/*.tspost.ts
jobjobs/*.tsprocessComment.ts
middlewaremiddlewares/*.tscalc.ts
routeroutes/*.tsindex.ts
schemaschemas/*.tspost.ts
serviceservices/*.tspost.ts
typetypes/*.tspost.ts
facadefacade.ts (root)—facade.ts

A scaffolded module looks like this:

src/modules/blog/
├── __tests__/
│   └── post.test.ts
├── console/
│   └── post.ts
├── controllers/
│   └── post.ts
├── database/
│   ├── models/
│   └── seeders/
├── helpers/
│   └── post.ts
├── jobs/
│   └── post.ts
├── middlewares/
│   └── post.ts
├── routes/
│   └── index.ts
├── schemas/
│   └── post.ts
├── services/
│   └── post.ts
├── types/
│   └── post.ts
└── facade.ts

Use the @/ alias, always

Every import inside src/modules/ uses the @/ alias, including files in the same module:

ts
// ✅ both of these
import { postController } from "@/modules/blog/controllers/post.controller.js";
import { postHelpers } from "@/modules/blog/helpers/post.helpers.js";

Relative paths (./, ../) are not used in module code. The alias keeps paths stable when you move a file between folders.

helpers/, services/, and middlewares/ are created empty (.gitkeep) by default — fill them with the matching module:make-* command when needed.

helper vs service — not the same ​

TestExamples
helperpure computation, no I/Oslugify("Hi There") → "hi-there"
hasRole(u, ["admin"]) → true
servicedoes something — DB, files, networksendPasswordEmail(user)
createOrder(data)

helper = computes. service = does.

If it touches the database, the filesystem, or the network, it is a service. Helpers stay trivially testable because they cannot fail on I/O.

Module facades ​

A facade.ts is a module's public API — the single door other modules knock on. It mirrors framework/facade.ts, whose own docblock already says "App-layer imports should target this file."

ts
// src/modules/auth/facade.ts
export { hasRole, getCurrentUser } from "./helpers/auth.helpers.js";
export { createAuthSchema } from "./controllers/auth.schema.js";

Other modules import from the door:

ts
// ✅ through the door
import { hasRole } from "@/modules/auth/facade.js";

// ❌ reaching into internals — breaks the moment you refactor
import { hasRole } from "@/modules/auth/helpers/auth.helpers.js";

The three rules ​

Rule 1 — Modules talk to each other ONLY through facades.

Rule 2 — Facades NEVER import other facades.

Rule 3 — Each module NEVER import/use its own facade.

Rule 2 exists because a facade re-exports everything in its module, so A/facade.ts → B/facade.ts → A/facade.ts loops trivially.

✅ WORKS
   A/facade.ts ──► B/helpers/tax.ts      (single file, NOT the facade)
   B/facade.ts ──► A/helpers/user.ts     (single file, NOT the facade)

❌ BREAKS
   A/facade.ts ──► B/facade.ts ──► A/facade.ts

❌ BREAKS
   A/facade.ts ──► A/helpers/user.ts (circuler import)

Escape hatch: anything you want to share goes in the facade. If a facade genuinely needs one file from another module, it imports that single file — never the other facade.

Inside vs outside a module ​

FromTo
modules/auth/controllers/a.tsmodules/auth/controllers/b.ts✅ same module, direct
modules/admin/post/…modules/auth/facade.ts✅ through the door
modules/auth/facade.tsmodules/auth/facade.ts❌ rule 1
modules/auth/facade.tsmodules/billing/facade.ts❌ rule 2
modules/auth/facade.tsmodules/auth/user.ts❌ rule 3
modules/blog/controllers/a.tsmodules/blog/services/b.ts✅ same module, direct

Decision guide ​

Where does the logic live?Where does it go?
Only used inside one modulethat module, beside its caller
Auth-flavored, needed by most modulesauth module → export via facade
Generic, needed by 2+ modulessrc/modules/shared/
Framework-stable, every app needs (db, jwt, mail)@/framework/facade.js

Why facades exist

Rule 1 is the same bug this framework already hit: config/session.ts imported config/index.js, which re-exported session. A cycle that only works because the import happens to resolve before first use — until it doesn't. Facades make the "public surface" explicit so the cycle checker can enforce it.

Circular imports ​

Database models are exempt. Drizzle's relations() defers cross-references into lazy callbacks, so user.ts ↔ role.ts must import each other. That mutual reference is structural, not accidental.

Everything else must be acyclic.

Models are skipped; src/resources/** (the Vue app) is excluded because it is not part of the module graph.

Module separation with panel ​

Sometimes we need to separate APIs by app or by panel. Suppose we have panel APIs—one for the backend and another for the frontend. If we separate them with their own panel names, it will be clear which API is actually for which panel. Similarly, if we separate multiple apps, it will also help in that case. It's very easy to do, you'd think.

bash
npm run maker module:make blog --path=admin
npm run maker module:make-controller blog post --path=admin
npm run maker module:make-route blog post --path=admin
npm run maker module:make-model blog post --path=admin
bash
pnpm maker module:make blog --path=admin
pnpm maker module:make-controller blog post --path=admin
pnpm maker module:make-route blog post --path=admin
pnpm maker module:make-model blog post --path=admin
bash
yarn maker module:make blog --path=admin
yarn maker module:make-controller blog post --path=admin
yarn maker module:make-route blog post --path=admin
yarn maker module:make-model blog post --path=admin
bash
bun maker module:make blog --path=admin
bun maker module:make-controller blog post --path=admin
bun maker module:make-route blog post --path=admin
bun maker module:make-model blog post --path=admin

Released under the MIT License.