Best Practices
Recipes are working wiring examples with the sharp edges annotated. This page and the
best-practicesrecipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.
Engineering defaults every ModularityJS project ships with. pnpm create @modularityjs scaffolds all of this; when assembling a project by hand, work through this checklist instead of improvising.
Dependencies: latest, but pinned
- Pin the toolchain.
"engines": { "node": ">=22.7.0" }and an exact"packageManager": "pnpm@<version>"inpackage.json, withengine-strict=truein.npmrc. Corepack keeps every machine on the pinned pnpm. - Commit the lockfile and install with
pnpm install --frozen-lockfilein CI — the lockfile is the pin;^ranges inpackage.jsonare for deliberate updates, not drift. - Keep every
@modularityjs/*package on the same version — the framework releases all packages in lockstep, so mixed versions are unsupported. - Update deliberately, stay current. Run
pnpm outdatedregularly;pnpm updatemoves within existing ranges; crossing a major requires the explicitpnpm update -L <package>. Old pinned versions are a liability — the goal is latest-and-pinned, not frozen. - Add and remove dependencies with
pnpm add/pnpm remove, never by hand-editing thepackage.jsondependency maps. The tool keeps them alphabetically sorted (enforced) and the lockfile in sync; a by-hand entry drifts and gets reordered on the next install or dependency-update run. This generalizes: reach tool-managed state through the tool (migrations via the CLI, versions via changesets), and hand-edit only when no tool produces what you need.
Build your app the way the framework is built
The contract/driver split, preferences, pools, and plugins aren't only how ModularityJS is assembled — they're the pattern for your own product code. Apply the same seams and your app inherits the same properties: testable, swappable, extendable without edits.
- Depend on an abstraction at every seam where variation, external I/O, or test-substitution lives. Define an abstract class (the contract) for the capability, bind the concrete implementation with a
preference, and@Injectthe abstract — never the concrete class. APaymentGatewaycontract with aStripePaymentGatewaypreference means your checkout code never names Stripe, tests bind a fake gateway with no network, and changing providers is one line in the module list. InjectingStripePaymentGatewaydirectly forecloses all three. - One swappable implementation → preference. Many coexisting contributors → pool. If a second implementation shipping alongside the first would be a bug, it's a preference (the clock, the payment gateway, the mailer). If it's a feature, it's a pool (notification channels, pricing rules, webhook handlers, dashboard panels): the consumer iterates the pool, and new behavior is added by contributing an entry — never by editing the consumer.
- Cross-cutting concerns → plugins, not inline edits. Telemetry, audit logging, retry wrapping belong in a
@Pluginthat intercepts the method, so business logic stays about the business and the concern applies uniformly across HTTP, CLI, and jobs. - Let boot-time validation carry the guarantees. Because every contract must have a provider at boot, consuming code needs no null checks or fallbacks — if it boots, it's wired. Lean on that instead of defensive branches.
But don't over-abstract. Not every class needs an interface. A contract earns its place at a real seam — a genuine second implementation, an external dependency to fake in tests, a declared extension point. Wrapping a leaf utility that has no variation in an abstract class is indirection without payoff: write it plainly and extract a contract when the second reason actually appears. The skill is choosing the seams, not maximizing them — a needless abstraction costs as much clarity as a missing one.
TypeScript
Strict mode, ESM only ("type": "module"), NodeNext module resolution with .js extensions on relative imports. Two tsconfigs: tsconfig.json (dev, includes tests) and tsconfig.build.json (extends it, excludes tests).
Coding standards and commit hygiene
ESLint via the framework's shared config —
eslint.config.mjs:javascriptimport { createStrictConfig } from '@modularityjs/coding-standard'; export default createStrictConfig(import.meta.dirname);createStrictConfigis what a scaffolded app gets: the generic base config plus the opinionated app-architecture rules (no-raw-throw,restrict-process-env,no-unknown-module-options,export-name-matches-file,no-persistence-in-controllers,no-console-in-business-code) and the ESM-cycle guard.createBaseConfigis the same thing without those opinions — drop to it only if you are deliberately turning the house style off.Prettier (
.prettierrc:{ "singleQuote": true }) plus an.editorconfig(2-space indent, LF, final newline).Conventional Commits, enforced —
commitlint.config.mjs:javascriptexport default { extends: ['@commitlint/config-conventional'] };Husky hooks as gates:
"prepare": "husky"in scripts;.husky/commit-msgrunsnpx --no -- commitlint --edit $1;.husky/pre-commitrunspnpm lint-staged. Never bypass them with--no-verify— fix the failure at the root cause.lint-staged in
package.json:eslint --fix+prettier --writeon stagedsrc/**/*.{ts,mts,mjs},prettier --writeon everything else it understands.
devDependencies this needs: @commitlint/cli, @commitlint/config-conventional, @modularityjs/coding-standard, eslint, husky, lint-staged, prettier.
Running the app
Use the modularity runner (@modularityjs/runner, a devDependency) instead of hand-writing invocation scripts: pnpm modularity dev / start / cli resolve entrypoints by convention and always apply --env-file-if-exists=.env, and unknown commands pass through to the app CLI (pnpm modularity database:migration:run). Keep package.json scripts as one-line delegations ("dev": "modularity dev") plus whatever is genuinely project-specific.
Definition of done
Run pnpm modularity verify before declaring any change done. It answers "is this app correctly assembled?" in order of increasing cost: environment checks → typecheck → boot check (the entire module graph wired and validated — contract fulfillment, pools, preferences — with no database or Redis required) → tests. --json emits a machine-readable step report; the exit code gates CI as-is. Boot failures carry stable MJS#### codes documented in the boot-errors catalogue.
CI
Every push and pull request runs the full loop: pnpm install --frozen-lockfile, then format:check, build, typecheck, lint, test. Any failure blocks the merge — don't relax a rule or rewrite an assertion to get to green.
Configuration and secrets
.envis never committed; ship a.env.exampledocumenting every variable. Run withnode --env-file-if-exists=.envso dropping in a.envlater needs no script changes.- Validate config at boot (config classes with
validate()), and fail loud in production when a secret is missing — a dev fallback must throw whenNODE_ENV === 'production', never silently ship the placeholder.
Database
Never hand-write migrations — generate them (cli database:migration:generate for TypeORM, prisma migrate dev for Prisma) and commit the generated files. If scaffolding before the CLI can run, stub a TODO migration and regenerate before merging.
Testing
Vitest, with the lane selected by file suffix through createVitestConfig({ lane }) from @modularityjs/coding-standard. Four lanes: unit (src/__tests__/**/*.spec.ts, in-process drivers, runs everywhere), integration (*.integration.spec.ts, hits real backends and skips loudly when one is absent), e2e (*.e2e.spec.ts, the whole app end to end), and bench (src/__bench__/**/*.bench.ts, run with vitest bench — timing, not assertions, so it is a measurement lane and not part of the pass/fail gate). Each non-unit lane needs its own vitest.<lane>.config.ts and a matching package.json script, or its specs silently never run. The bench lane is directory-scoped as well as suffix-scoped: a .bench.ts outside src/__bench__/ matches no lane glob, so it never runs — and compiles into dist. Boot test apps through the same bootstrap() the entrypoint uses, overriding only what the test needs.