Skip to content

Project Structure

Recipes are working wiring examples with the sharp edges annotated. This page and the project-structure recipe 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 @Module in 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's index.ts) — never deep-import another feature's files.
  • Feature modules import contract modules, not drivers. Import HttpModule, never HttpFastifyModule; the driver is listed once, in modules.ts. The exception is a pool that lives in a driver package — DatabaseEntitiesPool comes from @modularityjs/database-typeorm, so entity-contributing modules import that module directly.
  • bootstrap.ts isolates createApp so tests can boot the identical module graph with overrides, and index.ts stays a thin profile switch (server starts the HTTP listener; cli runs a command and exits).
  • The entrypoint names are what the modularity runner resolves (@modularityjs/runner, a devDependency): pnpm modularity dev / start / cli need no package.json scripts, and any framework CLI command passes through — pnpm modularity database:migration:generate AddUsers. Don't hand-write tsx watch --env-file-if-exists=.env src/server.ts scripts; delegate to the runner and keep package.json scripts for project-specific commands.
  • Relative imports use .js extensions (NodeNext resolution), and barrels are bare directory paths — never append /index.js.

A feature module, in full:

typescript
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 surface

The 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 injected DataSource (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/exception subclasses — 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 class contract 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 concrete SomethingClient injected 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) under src/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.ts implementing CliCommand, contributed to CliCommandsPool by 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.