This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
- Commands | Architecture | Guidelines | Troubleshooting
- Detailed Rules: See
.claude/rules/for in-depth documentation
Claude Code must actively maintain and improve this file and .claude/rules/.
After significant development sessions, update with new learnings (patterns, pitfalls, best practices). Keep this file concise (~150 lines) and move detailed topics to .claude/rules/.
The following documents must be kept up to date when making changes that affect them:
| Document | Update when... |
|---|---|
docs/REQUEST-LIFECYCLE.md |
Adding/changing decorators, interceptors, guards, pipes, middleware, plugins, services, modules, or the request/response flow |
migration-guides/ |
Releasing MINOR or MAJOR versions (see .claude/rules/migration-guides.md) |
.claude/rules/configurable-features.md |
Adding new configurable features |
FRAMEWORK-API.md |
Auto-regenerated by pnpm run build — verify after adding interfaces, CrudService methods, or core modules |
Rule: When a code change adds, removes, or modifies a feature listed in docs/REQUEST-LIFECYCLE.md (Features Overview, diagrams, decorator reference, configuration, etc.), update the document in the same commit or PR.
@lenne.tech/nest-server - An extension layer on top of NestJS for building server applications with GraphQL and MongoDB.
- NPM: https://www.npmjs.com/package/@lenne.tech/nest-server
- GitHub: https://github.com/lenneTech/nest-server
- Starter Project: https://github.com/lenneTech/nest-server-starter (reference for migrations)
- CLI: https://github.com/lenneTech/cli (
lt server module <Name>) - Package Manager: pnpm (development only, published to npm registry)
Consumer projects (created via lt fullstack init / lt server create)
can consume this framework in one of two modes. As a framework author
working in THIS repo, you should understand both so your changes are
compatible with either downstream layout:
-
npm mode (classic) — consumers install
@lenne.tech/nest-serveras an npm dependency. They read framework code fromnode_modules/@lenne.tech/nest-server/and import via the bare specifierfrom '@lenne.tech/nest-server'. This repo'sdist/is what they actually see at runtime. -
vendor mode (pilot) — consumers copy this repo's
src/core/(plussrc/index.ts,src/core.module.ts,src/test/,src/templates/,src/types/,LICENSE,bin/migrate.js) directly into their project at<api>/src/core/as first-class project code. No npm dependency. Imports use relative paths. Consumers apply a flatten-fix that movessrc/index.tsandsrc/core.module.tsundersrc/core/and rewrites./core/…→./…. Update flow is via thelt-dev:nest-server-core-updaterClaude Code agent, which clones this repo at a target ref, computes a structured delta against the consumer's baseline, and presents a curated review.
Implications for framework authors:
-
Keep
src/core/self-contained. Any new import insrc/core/**that points OUTSIDEsrc/core/(e.g.../server/...,../main) breaks vendor consumers, whosesrc/server/is unrelated to this repo's. Thesrc/server/here is a framework-testing example, not a shipping component. -
Keep top-level
src/index.tsandsrc/core.module.tsconsistent with the core directory structure. The flatten-fix strips./core/from specifiers in these two files, so if you add re-exports there, useexport * from './core/common/...'(not absolute paths). -
Top-level meta files like
FRAMEWORK-API.md,SHOWCASE.md,CHANGELOG.md, andload-tests/are stripped from vendor consumer projects duringconvertCloneToVendored. They're framework-author- only content.migration-guides/is kept because the updater agent reads it at sync time — keep the migration guides readable outside of an npm-install context (nonode_modules/…path references). -
package.jsondevDeps used at runtime by framework code (e.g.find-file-upin the config loader) must be listed in the CLI'ssrc/config/vendor-runtime-deps.jsonas a runtime helper, otherwise vendor consumers miss them at install time. Prefer putting runtime-used packages independenciesdirectly, notdevDependencies. -
Vendor-mode projects never run
pnpm update @lenne.tech/nest-server. They use/lt-dev:backend:update-nest-server-coreinstead, which does NOT bump apackage.jsonentry but rewritessrc/core/**in place. Do not assume version-bump telemetry from npm for vendored installs.
See the CLI's convertCloneToVendored() in
lenneTech/cli/src/extensions/server.ts for the full vendor
transformation, and the lt-dev:nest-server-core-updater agent for
the update-agent contract.
# Building & Running
pnpm run build # Build (outputs to dist/)
pnpm start # Start in local mode
pnpm run start:dev # Development mode with watch
# Testing (ALWAYS run before completing changes)
pnpm test # Run E2E tests (Vitest)
pnpm run test:cov # With coverage
npx vitest run --config vitest-e2e.config.ts --reporter=hanging-process # Debug open handles
pnpm run test:cleanup # Remove leftover test artifacts (.txt, .bin)
# Linting & Formatting
pnpm run lint # ESLint check
pnpm run lint:fix # Auto-fix
pnpm run format # Prettier format
# Package Development
pnpm run build:dev # Build for local development (use with pnpm link)
pnpm run reinit # Clean reinstall + tests + buildTwo-Layer Structure:
src/core/- Reusable framework components (exported)src/server/- Internal test implementation (not exported)src/index.ts- Public API exports
Key Components:
CoreModule- Dynamic module with GraphQL (optional, disable viagraphQl: false), MongoDB, securitysrc/core/common/- Decorators, helpers, interceptors, servicessrc/core/modules/- Auth, BetterAuth, ErrorCode, File, HealthCheck, Migrate, SystemSetup, Tus, User
See .claude/rules/architecture.md for detailed documentation.
See docs/REQUEST-LIFECYCLE.md for the complete request lifecycle, security architecture, and interceptor/decorator reference.
-
Module Inheritance Pattern - Extend through inheritance, not hooks/events
- See
.claude/rules/module-inheritance.md
- See
-
Dynamic Integration - Components opt-in, configurable, no package modification required
-
Backward Compatibility - Changes affect all consuming projects
-
Test Coverage - All changes must pass
pnpm test -
Export Management - New public components →
src/index.ts
The CoreAiModule ships with AiToolRegistry empty by default — it has NO automatic
access to any domain model. When integrating the AI module into a consumer project,
you MUST register tools for every domain operation that should be reachable from
chat (find_users, get_orders, etc.), otherwise the assistant correctly answers
"I don't have a tool to do that" for every domain question.
The pattern: extend AiTool, route through a CrudService with
context.serviceOptions (NEVER direct Model.find() — bypasses @Restricted and
securityCheck), mark mutating tools mutating: true and destructive ones
destructive: true. Group in an AiToolsModule and add it to ServerModule.imports.
Reference tools live at src/server/modules/ai/tools/ (find-users, get-user,
delete-user, update-user-job-title) — these are framework test fixtures, NOT
auto-registered in consumer projects. See src/core/modules/ai/INTEGRATION-CHECKLIST.md
§3 + §4 for the full step-by-step.
System roles (S_ prefix) are runtime checks only - NEVER store in user.roles:
@Roles(RoleEnum.S_USER) // Correct: runtime check
roles: [RoleEnum.S_USER] // WRONG: never store S_ roles!
roles: [RoleEnum.ADMIN] // Correct: real roleSee .claude/rules/role-system.md for complete documentation.
MAJOR.MINOR.PATCH where MAJOR = NestJS version, MINOR = breaking changes, PATCH = non-breaking.
See .claude/rules/versioning.md for release process.
The starter includes a multi-stage Dockerfile that works standalone and in monorepos:
docker build -t api . # Standalone
docker build --build-arg API_DIR=projects/api -t api . # MonorepoKey files: Dockerfile, docker-entrypoint.sh (migrations + server start), .dockerignore
The migration store uses NSC__MONGOOSE__URI env var (not config.env.ts) for Docker compatibility.
// config.env.ts supports:
- Direct environment variables
- NEST_SERVER_CONFIG JSON variable
- NSC__* prefixed variablesKey areas: JWT, MongoDB, GraphQL, email, security, betterAuth
| Issue | Solution |
|---|---|
| Tests timeout | Ensure MongoDB running on localhost:27017 |
| GraphQL introspection fails | Check config.env.ts introspection setting |
| Module not found after adding | Verify export in src/index.ts, run pnpm run build |
| Open handles in tests | Run vitest with --reporter=hanging-process |
- All code, comments, documentation in English
- Run tests before completing changes -
pnpm test - Follow existing patterns for consistency
- Never store S_ roles in user.roles array
- Use Module Inheritance Pattern for core modules
- Document breaking changes in commits
- Integration Checklists for Core Modules - Every core module requiring project integration needs
INTEGRATION-CHECKLIST.md(see.claude/rules/core-modules.md) - Don't add redundant @UseGuards -
@Roles()already handles JWT auth (see.claude/rules/role-system.md) - Fixed package versions only - Never use
^,~, or ranges in package.json (see.claude/rules/package-management.md)
When releasing MINOR or MAJOR versions, create migration guides in migration-guides/:
- Use
migration-guides/TEMPLATE.mdas starting point - Always analyze
src/server/and nest-server-starter - Ask developer for additional projects to analyze
- See
.claude/rules/migration-guides.mdfor complete process
Detailed documentation in .claude/rules/:
| File | Content |
|---|---|
module-inheritance.md |
Core architectural pattern for extending modules |
role-system.md |
Role system, S_ prefix rules, @Roles vs @UseGuards |
architecture.md |
Detailed code architecture |
testing.md |
Test configuration and best practices |
versioning.md |
Version strategy and release process |
core-modules.md |
Path-scoped rules for src/core/modules/ incl. Integration Checklist requirements |
better-auth.md |
Security-critical: Better-Auth module rules (standard compliance, security, testing) |
module-deprecation.md |
Legacy Auth → BetterAuth migration roadmap |
migration-guides.md |
Process for creating version migration guides |
configurable-features.md |
Configuration patterns: "Presence implies enabled" and "Boolean shorthand" (true / {}) |
package-management.md |
Fixed package versions only - no ^, ~, or ranges |
framework-compatibility.md |
Maintenance obligations for FRAMEWORK-API.md, shipped docs, cross-repo dependencies |
Access to this.mainDbModel.collection and this.mainDbModel.db is blocked via TypeScript type (SafeModel = Omit<Model, 'collection' | 'db'>).
For additionally injected models (@InjectModel): NEVER use .collection.* or .db.*.
All Mongoose plugins (Tenant, Audit, RoleGuard, Password) are bypassed.
Use Mongoose Model methods instead:
| Forbidden | Allowed |
|---|---|
collection.insertOne(doc) |
Model.insertMany([doc]) |
collection.bulkWrite(ops) |
Model.bulkWrite(ops) |
collection.updateOne(f, u) |
Model.updateOne(f, u) |
collection.updateMany(f, u) |
Model.updateMany(f, u) |
collection.deleteOne(f) |
Model.deleteOne(f) |
collection.deleteMany(f) |
Model.deleteMany(f) |
collection.find(f) |
Model.find(f) or Model.find(f).lean() |
collection.aggregate(p) |
Model.aggregate(p) |
If native access is unavoidable: use this.getNativeCollection(reason) or this.getNativeConnection(reason) from CrudService.
Allowed for:
- Collections without Mongoose schema (OAuth, BetterAuth, MCP)
- Read-only aggregations/counts on other collections
- Admin operations (indexes, drops, backups)
NOT allowed for:
- Write operations on tenant-scoped collections → use Mongoose Model instead
- CRUD on collections that have a Mongoose schema → use CrudService instead
Details: docs/native-driver-security.md
The process() pipeline (prepareInput → checkRights → serviceFunc → processFieldSelection → prepareOutput → checkRights) adds memory overhead per call through object cloning, Mongoose hydration, and populate operations. For typical API usage this is negligible, but it can become significant in:
- High-frequency operations (e.g. monitor checks running every 10-60 seconds per monitor)
- Service cascades (Service A → Service B → Service C, each going through process())
- Populate chains (3-5 levels of nested population)
CrudService.create/update/get() wraps every operation in process() which provides:
- Input validation (prepareInput: type mapping, ObjectId conversion)
- Authorization (checkRights:
@Restricted,S_CREATOR,S_SELF, field-level permissions) - Output filtering (prepareOutput: recursive secret removal, recursive ObjectId→string conversion, strip restricted fields)
Use CrudService when the caller is a user-facing API (Controller → Service → Response to User) — authorization and field filtering are required.
Use direct Mongoose when the caller is system-internal (Processor, Cron, Service-to-Service) — no user context exists, no response is sent to a user, and input is internally controlled.
| Context | Use | Why |
|---|---|---|
| Controller handling user request | CrudService.update() |
User needs authorization + field filtering |
| Cron job updating internal state | Model.findByIdAndUpdate() |
No user context, no response to user |
| Queue processor writing results | Model.create() |
System-internal, plugins provide tenant/audit |
| WebSocket emit loading data | Model.findById().lean() |
Read-only, clients receive JSON |
| Appending to subdoc array | CrudService.pushToArray() |
Always — even in controllers (see rule 6 below) |
| Removing from subdoc array | CrudService.pullFromArray() |
Atomic removal, bypasses process() |
These alternatives bypass process() but keep Mongoose plugins (Tenant, Audit, RoleGuard) active. Only use in system-internal contexts where no user authorization or output filtering is needed.
| Instead of | Use | Relative Speed | Relative Memory |
|---|---|---|---|
CrudService.create(input) |
Model.create(input) |
~3x faster than save() |
~9x less than save() |
CrudService.create(input) (batch N) |
Model.insertMany(docs) |
~2.5x faster than N× save() |
~3x less than N× save() |
CrudService.update(id, input) |
Model.findByIdAndUpdate(id, input).lean() |
Fastest update pattern | Lowest memory |
CrudService.get(id) / getForce(id) |
Model.findById(id).lean() |
~2x slower per call | ~5x less memory |
Note:
Model.create()is significantly faster and lighter thannew Model().save()for single documents.findById().lean()trades per-call speed for much lower memory — prefer lean in high-frequency paths where memory stability matters more than per-call latency. For low-frequency user-facing paths, hydrated reads via CrudService are preferred.Model.create()internally callssave()but is optimized by Mongoose — it triggers allpre('save')hooks (Tenant, Audit, RoleGuard, Password).
NEVER bypass Mongoose entirely via collection.* — see section above. The CheckSecurityInterceptor acts as a safety net on HTTP responses regardless of how data was written.
Secret removal happens in two layers:
prepareOutput(service-level, inprocess()): Recursively removespassword,verificationToken,passwordResetToken— fields that are NEVER needed internally after hashing/use.CheckSecurityInterceptor(HTTP-level, Safety Net): Removes the full configurablesecurity.secretFieldslist (additionallyrefreshTokens,tempTokens) — these are needed internally for token validation and process flows but must not reach HTTP clients.
These rules apply when building features that execute many times per minute (monitoring, metrics, queue processors). These paths are always system-internal — no user context, no authorization needed.
-
Model.create(doc)overnew Model().save()for single documents —create()is ~3x faster and uses ~9x less memory. For batch inserts (N>1), useModel.insertMany(docs)— a single call with N documents is ~2.5x faster than N parallelsave()calls. -
Model.findById().lean()overgetForce()for read-only lookups — lean uses ~5x less memory but is ~2x slower per call. In hot paths, the memory savings outweigh the latency cost because reduced GC pressure improves overall throughput. For low-frequency user-facing paths, hydrated reads via CrudService are preferred (faster + authorization). -
Defer complex logic to cron/queue — high-frequency paths should only write data (checks, metrics). Processing that data (incident creation, notifications, escalation) belongs in low-frequency cron jobs or separate queue processors that run in bounded batches.
-
Avoid service cascades in hot paths — if Service A calls Service B which calls Service C, each with
process(), the objects from all three levels live in the heap simultaneously. In hot paths, call Mongoose directly instead of going through other services. -
WebSocket emits should use lean data —
emitUpdated()withgetForce()triggers a fullprocess()pipeline per emit. UseModel.findById().lean()instead — WebSocket clients receive JSON, not Mongoose Documents. -
NEVER pass Mongoose SubDocument Arrays through
CrudService.update()— this applies in ALL contexts (user-facing AND system-internal). Mongoose subdocument arrays (e.g.entity.logs,entity.comments,entity.history) are Proxy-wrapped objects. When passed throughprocess(),clone()(rfdc) andprocessDeep()trigger Proxy getters/setters on every property of every subdocument, andmergePlain()clones the entire array again. On long-lived documents with hundreds of entries this causes OOM (3GB+ heap). UseCrudService.pushToArray()/pullFromArray()or direct$push/$set:// WRONG — even in a controller, this causes OOM on large arrays entity.logs.push(newLog); await this.entityService.update(id, { logs: entity.logs }); // CORRECT — use CrudService helper (preferred) await this.entityService.pushToArray(id, 'logs', newLog); await this.entityService.pushToArray(id, 'logs', newLog, { $slice: -500 }); // cap at 500 // CORRECT — direct Mongoose for combined operations await entityModel.findByIdAndUpdate(id, { $push: { logs: newLog }, $set: { status: 'RESOLVED', resolvedAt: new Date() }, }).lean().exec(); // CORRECT — remove items by condition await this.entityService.pullFromArray(id, 'tags', 'obsolete');
-
Clear timers in Promise.race patterns — if using
Promise.race([operation, timeout]), always clear the timeout after the race resolves. Leaked timers accumulate memory and cause unhandled rejections in high-frequency paths. -
In-memory buffers need eviction — any
Mapor array used as a buffer (error tracking, dedup, cache) must have a size cap and periodic cleanup. Without eviction, buffers grow unbounded and are never GC'd. -
Queue-based crons must prevent re-enqueue of in-progress items — if a cron adds jobs to a BullMQ queue faster than the processor consumes them, the queue grows unbounded. Guard with
getWaitingCount()check before enqueuing.
Details: docs/process-performance-optimization.md
Details: docs/subdocument-array-optimization-plan.md
Rule: Single-field indexes live on the property. Schema.index() is reserved for compound (multi-field) indexes only.
-
Single-field indexes → declare directly on the property via
@Prop({ index: true })or@UnifiedField({ mongoose: { index: true } }). Keeps all property info in one place and visible during code review. -
Framework-managed indexes → do NOT set manually. Example:
tenantIdis automatically indexed by themongooseTenantPlugin(src/core/common/plugins/mongoose-tenant.plugin.ts→schema.index({ tenantId: 1 })). Addingindex: trueontenantIdin a consumer project triggers Mongoose"Duplicate schema index"warnings. Any future plugin that auto-indexes a field must be documented here. -
Compound (multi-field) indexes → only these belong in
Schema.index({ field1: 1, field2: 1 })afterSchemaFactory.createForClass(...). This is the only acceptable use ofSchema.index(). -
TTL indexes → declare inline:
@Prop({ required: true, index: { expireAfterSeconds: 3600 } }). Do NOT duplicate with a separateSchema.index()call — the single-field TTL belongs on the property. -
unique: trueimplies an index → Mongoose auto-creates a unique index for@Prop({ unique: true }). Do not additionally declareSchema.index({ field: 1 }, { unique: true }).
Why: Property-local indexes are immediately visible when reviewing a field. Hidden Schema.index() calls at the bottom of the file are easy to miss, leading to duplicate definitions and Mongoose warnings when multiple developers touch the same model. When building plugins that auto-index fields, always emit a warning in the plugin's docstring so consumers know not to set the index manually.
| File | Content |
|---|---|
docs/REQUEST-LIFECYCLE.md |
Complete request lifecycle, security architecture, interceptor chain, decorator reference, CrudService pipeline, Safety Net, diagrams |
docs/native-driver-security.md |
Native MongoDB Driver restrictions, secure alternatives, review checklist |
docs/process-performance-optimization.md |
process() pipeline performance optimizations |
docs/subdocument-array-optimization-plan.md |
SubDocument array handling (implemented): pushToArray/pullFromArray, checkRestricted optimization, bug fixes |