Project Structure
Recipes are working wiring examples with the sharp edges annotated. This page and the
project-structurerecipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.
Recommended layout for a ModularityJS app — the shape the framework's own demo apps follow and the target to grow toward. (pnpm create @modularityjs scaffolds a minimal flat starter — entrypoints under src/, hello controller beside them; move to feature folders as the app grows.) Deviate deliberately, not by accident.
src/
index.ts — entrypoint: pick a profile ('server' | 'cli'), bootstrap, start
bootstrap.ts — the only createApp call: createApp({ di: inversify, modules, profiles })
modules.ts — the module list; drivers are picked here and only here
app.module.ts — root app module (app-wide providers, pools, config)
migrations/ — generated database migrations (never hand-written — see Hard rules)
modules/<feature>/ — one folder per feature
index.ts — barrel: re-exports the module + anything siblings may import
<feature>.module.ts — the @Module: imports, providers, pools
<feature>.controller.ts
<feature>.entity.ts
schemas.ts — validation schemas for the feature's routes
shared/ — cross-feature modules, entities, plugins (with its own barrel)
__tests__/ — Vitest specs (unit *.spec.ts; integration *.integration.spec.ts)Rules that make the layout work:
- One feature = one module folder, one
@Module. Controller, entities, and schemas live next to the module that registers them. A second@Modulein the same folder is a smell — split the feature. - Reach other features only through barrels. Cross-feature reuse goes through
shared/(or the sibling feature'sindex.ts) — never deep-import another feature's files. - Feature modules import contract modules, not drivers. Import
HttpModule, neverHttpFastifyModule; the driver is listed once, inmodules.ts. The exception is a pool that lives in a driver package —DatabaseEntitiesPoolcomes from@modularityjs/database-typeorm, so entity-contributing modules import that module directly. bootstrap.tsisolatescreateAppso tests can boot the identical module graph with overrides, andindex.tsstays a thin profile switch (serverstarts the HTTP listener;cliruns a command and exits).- The entrypoint names are what the
modularityrunner resolves (@modularityjs/runner, a devDependency):pnpm modularity dev/start/clineed no package.json scripts, and any framework CLI command passes through —pnpm modularity database:migration:generate AddUsers. Don't hand-writetsx watch --env-file-if-exists=.env src/server.tsscripts; delegate to the runner and keep package.json scripts for project-specific commands. - Relative imports use
.jsextensions (NodeNext resolution), and barrels are bare directory paths — never append/index.js.
A feature module, in full:
import {
DatabaseEntitiesPool,
DatabaseTypeormModule,
} from '@modularityjs/database-typeorm';
import { HttpControllersPool, HttpModule } from '@modularityjs/http';
import { Module } from '@modularityjs/modularity';
import { Note } from './note.entity.js';
import { NotesController } from './notes.controller.js';
@Module({
name: 'app-notes',
imports: [HttpModule, DatabaseTypeormModule],
providers: [NotesController],
pools: [
{ pool: DatabaseEntitiesPool, key: 'note', useValue: Note },
{ pool: HttpControllersPool, key: 'notes', useClass: NotesController },
],
})
export class NotesModule {}When the module list grows past a screen, switch modules.ts to defineModules() from @modularityjs/autoload and generate the wiring shell with pnpm exec modularityjs-autodiscover (emits autoloaded.ts — generated, never edited by hand). modules.ts stays the single source of truth for install/config/app/profiles.
The domain blueprint (scale a feature into a layered module)
A feature that owns persistence and business rules — not just a controller — should follow one uniform layout so every such module in the codebase reads the same way. pnpm modularity generate domain <name> scaffolds exactly this skeleton (adapting to your installed capabilities) and wires it in; reach for it instead of hand-assembling, so the shape is identical every time:
src/modules/<name>/
<name>.module.ts — the @Module: imports, providers, pools
<name>.config.ts — @Injectable config class; the ONE place this module reads env
<name>.service.ts — owns ALL persistence and business logic
<name>.entity.ts — persistence model (when using a database)
<plural>.controller.ts — HTTP surface, mounted at /api/v1/<plural>
<name>-schemas.ts — request validation schemas
<name>-input.ts — request DTO types
<name>-types.ts — domain types, status unions
internal/ — helpers (pure functions, parsers, math); keeps the root role-suffixed only
index.ts — barrel: the module's public surfaceThe invariants that make it uniform — and the reason each exists:
- Persistence lives in the service, never the controller. A controller that imports
typeorm(or any driver) has leaked the data layer into the HTTP layer; inject the service instead. Services get their repository from an injectedDataSource(dataSource.getRepository(Entity)). - Read configuration through the config class, never scattered
process.env. Env access belongs in<name>.config.ts(validated at boot); everywhere else injects the config. This is what makes a module testable without mutating the environment. - Throw structured exceptions, never
throw new Error(...). Use@modularityjs/exceptionsubclasses — a bare Error becomes an unstructured 500 with no machine-readable kind. - One helper home. Pure helpers go in
internal/, so the module root is only role-suffixed files and stays scannable as the module grows. - Any external-system integration gets a contract, not a bare client. When a module talks to an external technology (a hypervisor, a payment API, a message broker), define an
@Injectable() abstract classcontract and bind the concrete driver via a preference — then inject the abstract. This is the framework's own contract/driver pattern applied to your integrations: it makes the driver swappable and, more importantly, makes everything that depends on it unit-testable with a fake. A concreteSomethingClientinjected directly is the single most common place this discipline is skipped and the most expensive to retrofit.pnpm modularity generate integration <name>scaffolds exactly this seam (abstract client + HTTP driver + config + preference wiring) undersrc/integrations/<name>/— reach for it instead of writing a bare client. - Project tasks are CLI commands, not ad-hoc scripts. A backfill, report, or sync job becomes a
*.command.tsimplementingCliCommand, contributed toCliCommandsPoolby its module, and run through the booted app (pnpm modularity cli <name>) — so it gets DI, config, and lifecycle for free instead of a side-script with its own bootstrapping.pnpm modularity generate cli-command <name>scaffolds the command + module + spec and wires it in; colon-namespaced names (report:daily) follow the house convention.
Enforce it, don't just document it. Prose conventions drift; the ones above that are mechanically checkable are ESLint rules (no-raw-throw, restrict-process-env, no-unknown-module-options, export-name-matches-file, no-persistence-in-controllers, no-console-in-business-code, require-config-validate, feature-module-boundary) — enable them via createStrictConfig from @modularityjs/coding-standard (scaffolded apps get this by default) so pnpm modularity verify fails on a violation instead of a reviewer catching it later. Treat the blueprint as your project's house style: adjust it deliberately, then let the generator reproduce it and the linter hold the line.