Skip to content

Best Practices

Recipes are working wiring examples with the sharp edges annotated. This page and the best-practices recipe 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>" in package.json, with engine-strict=true in .npmrc. Corepack keeps every machine on the pinned pnpm.
  • Commit the lockfile and install with pnpm install --frozen-lockfile in CI — the lockfile is the pin; ^ ranges in package.json are 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 outdated regularly; pnpm update moves within existing ranges; crossing a major requires the explicit pnpm 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 the package.json dependency 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 @Inject the abstract — never the concrete class. A PaymentGateway contract with a StripePaymentGateway preference 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. Injecting StripePaymentGateway directly 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 @Plugin that 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:

    javascript
    import { createStrictConfig } from '@modularityjs/coding-standard';
    
    export default createStrictConfig(import.meta.dirname);

    createStrictConfig is 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. createBaseConfig is 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:

    javascript
    export default { extends: ['@commitlint/config-conventional'] };
  • Husky hooks as gates: "prepare": "husky" in scripts; .husky/commit-msg runs npx --no -- commitlint --edit $1; .husky/pre-commit runs pnpm lint-staged. Never bypass them with --no-verify — fix the failure at the root cause.

  • lint-staged in package.json: eslint --fix + prettier --write on staged src/**/*.{ts,mts,mjs}, prettier --write on 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

  • .env is never committed; ship a .env.example documenting every variable. Run with node --env-file-if-exists=.env so dropping in a .env later 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 when NODE_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.