Home / Articles

Teaching Claude Code Your NestJS Monorepo: Routing, Rules and Skills

Teach Claude Code a NestJS monorepo: CLAUDE.md, rules, skills and permissions that put code in the right service and keep it following team conventions.

Publisher: REACTAPP.TOP20 min read4492 words

This article is published in English.

Sources: Claude Code for NestJS Monorepos: A Practical Setup Guide

Give an unconfigured AI assistant a request like "add a consumer-visible status to a booking" in a large NestJS monorepo, and the result is predictable: the change lands in the wrong microservice, the error message is a hardcoded string, and access is guarded by user.role === 'admin'. The model is not failing at TypeScript; it is guessing at decisions your team made long ago and never wrote down where it could read them. This guide shows how to replace that guessing with version-controlled context, using the four building blocks Claude Code offers: CLAUDE.md, rules, skills and permission settings. By the end you will have a layout you can commit to your repository so every engineer, and every AI session, starts from the same conventions.

If you want general advice on writing the briefing file itself, see our playbook for an effective CLAUDE.md. This article focuses on what changes when the codebase is a multi-service NestJS backend.

The short version

For readers who want the checklist before the reasoning:

  1. Install Claude Code (for example with npm install -g @anthropic-ai/claude-code) and authenticate once by running claude.
  2. Create a root .claude/CLAUDE.md that explains which service owns which data, the order of your request pipeline, and the response envelope every endpoint returns.
  3. Add a .claude/rules/ directory with prohibitions: no queries inside loops, no checks against role names, no relative imports.
  4. Add a .claude/skills/ directory containing step-by-step workflows and code templates for tasks you repeat, such as creating an endpoint.
  5. Add .claude/settings.json to pre-approve safe commands and block destructive ones.
  6. Commit the whole .claude/ directory so the configuration is shared and reviewed like code.

Why a NestJS monorepo confuses AI assistants

In a big NestJS codebase, whether code is correct often depends on conventions that no single file reveals. Dozens of modules, hundreds of DTOs, custom guards, interceptors, exception filters and HTTP adapters create real ambiguity. When nothing resolves that ambiguity, the model falls back on sensible generic defaults, and generic defaults rarely match a specific team.

Typical symptoms of an unconfigured setup:

  • A service method talks to the database directly instead of going through the repository layer.
  • Error messages are string literals rather than entries from an ErrorMessages constants file.
  • A new file is created in operator-service even though booking logic belongs to api-service.
  • Authorization is written as user.role === 'admin' instead of using PermissionCodes.
  • Debug output uses console.log instead of the structured logger.
  • Imports climb directories with ../../common/utils instead of using the src/ path alias.

The root cause is missing tribal knowledge

None of these are model bugs. During a session the assistant only knows the files it has opened. It has no way to learn that the team deliberately deleted RolesGuard, that TypeORM's synchronize option must remain false, or that services talk to each other exclusively through HTTP adapters. That knowledge lives in engineers' heads and in old pull request threads.

The remedy is to write that knowledge down in files the tool loads, and to match each kind of knowledge to the right mechanism:

  • CLAUDE.md holds architecture, service ownership and conventions. It is loaded automatically at the start of each session.
  • Rules hold prohibitions and invariants that should shape every piece of generated code.
  • Skills hold multi-step workflows and templates, and are pulled in only when a matching task comes up.

The repository layout this guide assumes

It helps to fix a concrete structure before writing any configuration. The example backend has four services and a single .claude/ directory at the repository root:

my-backend/
├── .claude/                  # Everything Claude needs to operate well
│   ├── CLAUDE.md             # Root-level instructions (always loaded)
│   ├── rules/                # Enforced coding constraints
│   ├── skills/               # Custom slash commands with deep context
│   └── settings.json         # Command permissions
├── services/
│   ├── api-service/          # NestJS consumer API (port 5001)
│   ├── operator-service/     # NestJS operator/vendor API (port 5002)
│   ├── chat-service/         # Express + Socket.IO (JavaScript, port 5005)
│   └── job-service/          # Scheduled jobs, no HTTP server
├── db/migration/             # Flyway migrations per schema
└── package.json              # Root: only husky + lint-staged

Each service is a self-contained application with its own package.json, tsconfig.json and build output. Note that one of them, chat-service, is plain JavaScript on Express and Socket.IO, and job-service has no HTTP server at all. That heterogeneity is exactly the kind of fact an assistant cannot infer from a single file.

Within every NestJS service, feature modules share the same three-layer shape:

src/modules/booking/
├── booking.module.ts
├── controllers/    # HTTP routing only, no logic
├── services/       # Business logic and orchestration
├── repositories/   # TypeORM queries only
└── dtos/           # Request/response shapes, validation

Controllers route HTTP requests and nothing more, services contain business logic and orchestration, and repositories contain TypeORM queries. If the assistant has never been told about this split, it will happily blur it: a service issuing queries, or a controller calling a repository directly. Writing the layering down is what lets it respect the boundary.

Step 1: install and authenticate

Installation is a single global package, followed by a one-time browser login:

npm install -g @anthropic-ai/claude-code
claude   # opens a browser for one-time OAuth

Once authenticated, claude can be launched from any directory. Every developer installs and signs in on their own machine; there is no shared team key to distribute. Installation options change over time, so check the current Claude Code documentation if the npm route is no longer the recommended one.

One detail matters specifically for monorepos. Project configuration is picked up relative to the repository root, so instructions in the root .claude/ apply wherever you start a session. When your work is confined to one service, start Claude from inside that service directory, such as services/api-service/, so that searches and commands run against the right package.

Step 2: write the CLAUDE.md briefing

CLAUDE.md is the file with the biggest impact, because it is the context the assistant reads before anything else. Write it the way you would brief a senior engineer on their first day: assume general competence, explain only what is specific to your system. In a monorepo it makes sense to have two levels:

  1. A root .claude/CLAUDE.md for service ownership, shared conventions and the request pipeline.
  2. A services/{service}/CLAUDE.md per service for module patterns, authentication and dependency injection details that only apply there.

Root file: make service ownership a decision, not a guess

The most expensive mistake in a multi-service repository is putting code in the wrong place, because it passes local review and only shows up later as a boundary violation. The root file prevents it with a routing table and an explicit list of forbidden patterns. Here is an example of what that file can contain:

## Monorepo StructureFour services under `services/`, each independently runnable.
Run all commands from inside the service directory.| Service            | Port | Stack                    | Purpose              |
| ------------------ | ---- | ------------------------ | -------------------- |
| `api-service`      | 5001 | NestJS + TypeScript      | Consumer-facing API  |
| `operator-service` | 5002 | NestJS + TypeScript      | Operator/vendor mgmt |
| `chat-service`     | 5005 | Express + Socket.IO + JS | Real-time messaging  |
| `job-service`      | —    | Node.js + TypeScript     | Scheduled jobs       |## Which Service Owns What- **operator-service** — if the data describes _what an experience is_:
  package definitions, pricing, taxes, availability, operator accounts.
- **api-service** — if the data describes _a consumer's interaction_:
  bookings, users, auth, trip planning, payments, notifications.| Task involves...                   | Service                            |
| ---------------------------------- | ---------------------------------- |
| Package categories, pricing, slots | operator-service                   |
| Booking status, user profiles      | api-service                        |
| Stripe payments (consumer side)    | api-service                        |
| Cross-service booking flow         | Start in api-service → IMS adapter |## Global Request Pipeline (in order)1. `TraceContextMiddleware` — sets `correlationId` in `AsyncLocalStorage`
2. `ValidationPipe` — whitelist mode, flattens DTO errors
3. `LoggerInterceptor` — logs request/response
4. `TransformInterceptor` — wraps return value in the standard shape
5. Exception filters (`HttpExceptionFilter`, `TypeOrmExceptionFilter`, …)## Standard Response ShapeControllers return `{ data, meta }`; `TransformInterceptor` wraps it:
return { data: { items }, meta: { path: request.path } };
// → { success: true, data: { items }, meta: { path }, error: null }## Cross-Service Communicationapi-service NEVER touches operator-service's database. All reads/writes go
through HTTP adapters in `src/common/adapters/` (they handle OAuth refresh and
trace propagation). Need operator data? Call the adapter, never the DB.## What Claude Must Never Do- Use `console.log` → use `logMessage()` from `src/common/utils/logger`
- Check `user.role` → use `PermissionCodes` + `@Permissions()`
- Query the DB inside a loop → use bulk queries (`IN`, `JOIN`)
- Use relative `../../` imports → use the `src/` alias
- Set TypeORM `synchronize: true` → Flyway manages all schema changes

Several parts of this file deserve attention. The ownership section is phrased as a test ("does the data describe what an experience is, or a consumer's interaction with it?") rather than as a directory listing, which lets the assistant classify requests it has never seen. The pipeline section lists middleware, pipes, interceptors and filters in execution order, so generated code does not duplicate what TransformInterceptor already does. The cross-service section states flatly that api-service never touches the operator database and must go through the adapters in src/common/adapters/.

What separates a useful CLAUDE.md from a weak one:

  • Decision rules beat file trees. A rule like "data describing what an experience is belongs in operator-service" resolves ambiguity without a clarifying question.
  • State the response shape. Without it, controllers each wrap their own responses and the envelope drifts.
  • Spell out what is forbidden. "Never use console.log" is easier to act on than "use our logger".
  • Record deliberate removals. If you removed RolesGuard or turned off synchronize, say so and say why. Otherwise the assistant may reintroduce them, precisely because they look like the idiomatic NestJS choice.

Service-level files: depth that only one service needs

Details that matter only inside one service go into that service's own file. For api-service, that covers layering, authentication, DI tokens and background jobs:

## Module Architecture — never mix layerscontrollers/ HTTP routing only — call service, return data
services/ Business logic — orchestrate repos, no direct DB calls
repositories/ TypeORM queries only — accept EntityManager for transactions## Authentication- `JwtAuthGuard` validates Bearer tokens, attaches `UserRequest`
- `PermissionsGuard` reads `@Permissions([PermissionCodes.X])`
- Never use RolesGuard or check role names — removed intentionally## Dependency InjectionRegister services by interface token, and inject by token, not class:
{ provide: SERVICE_INTERFACE.BOOKING_SERVICE, useClass: BookingService }## Background JobsBullMQ + Redis. Processors live in `src/modules/{module}/processors/`.
Enqueue via the module's service — never from a controller.

Two points here are easy to get wrong without guidance. First, providers are registered and injected through interface tokens such as SERVICE_INTERFACE.BOOKING_SERVICE, not by class, so a generated constructor that injects BookingService directly would compile but break the team's pattern. Second, BullMQ jobs are enqueued only from a module's service, never from a controller, and processors live under src/modules/{module}/processors/. Keeping these facts out of the root file means they do not consume context when you are working in chat-service.

Step 3: add rules for things that must never happen

Where CLAUDE.md describes how the system works, rules describe what generated code must not contain. A useful mental model is an ESLint configuration aimed at AI output. Rules are Markdown files inside .claude/rules/.

A simple way to decide where something goes:

  • Architecture, module structure, service ownership, conventions and command references belong in CLAUDE.md.
  • Hard prohibitions, patterns that must never be emitted, and security or performance invariants belong in rules/.

Keep in mind that rules are instructions loaded into the model's context, not a static analyzer. They make violations far less likely, but for invariants that truly matter you should still back them with lint rules, tests or CI checks.

Rule: no N+1 queries

N+1 queries are easy to write and easy to miss in review, because each individual line looks innocent. A rule in .claude/rules/database-queries.md works best when it shows both the forbidden pattern and the replacement. The forbidden version issues one query per booking:

// Wrong — N queries
for (const b of bookings) {
  b.user = await this.userRepo.findOne(b.userId);
}

The replacement loads all users in a single query with TypeORM's In operator, then attaches them in memory through a Map keyed by id:

// Correct — one query
const users = await this.userRepo.find({ where: { id: In(userIds) } });
const userMap = new Map(users.map((u) => [u.id, u]));
bookings.forEach((b) => {
  b.user = userMap.get(b.userId);
});

The payoff is that the bulk query is written at generation time, so the performance bug never reaches a pull request. Remember to collect userIds from the bookings first, and consider deduplicating them when many bookings share a user.

Rule: permissions, never role names

The second rule, .claude/rules/permissions.md, bans ad hoc role checks like this one:

// Wrong
if (user.roles.includes('admin')) { ... }

Instead, endpoints declare the permission they need and let the guards enforce it:

// Correct
@Permissions([PermissionCodes.MANAGE_BOOKINGS])
@UseGuards(JwtAuthGuard, PermissionsGuard)

The reasoning belongs in the rule file too, because it helps the model generalize: roles get merged, split and renamed as a product grows, and every check written against a role name breaks quietly when that happens. Permission codes are stable constants that survive such reorganizations. The rule should also say where they live, in this case src/common/constants/permissions.constant.ts.

Rule: path aliases instead of relative imports

A third rule, .claude/rules/imports.md, standardizes imports:

// Wrong
import { UserService } from "../../services/user.service";
// Correct
import { UserService } from "src/modules/user/services/user.service";

The alias works because tsconfig.json sets baseUrl to "./" and Jest's moduleNameMapper maps the same prefix. Mentioning both matters: if the assistant only knew about the TypeScript side, it might conclude the alias is unsafe in tests.

More rules worth writing

The same "forbid and show the alternative" format fits many recurring NestJS problems. Candidates for their own files:

  • Error handling: throw NestJS exceptions built from ErrorMessages or ResponseCodeKeys constants; never hardcode messages or return raw error objects.
  • Logging: use the structured logMessage() helper with the appropriate level; console.log is off limits.
  • Transactions: operations that write more than once must share one EntityManager transaction, so a failure cannot leave data half-committed.
  • DTO validation: every request DTO needs class-validator decorators. Because ValidationPipe runs in whitelist mode, properties without decorators are stripped silently, which looks like data loss rather than an error.
  • Entity and migration parity: any entity change needs a matching Flyway migration, and synchronize stays false.
  • Swagger coverage: each endpoint gets @ApiSpec and a typed response DTO so the generated API documentation stays accurate.

Step 4: turn repeated workflows into skills

Skills are project-specific slash commands that bundle a procedure, templates and reference material. They are the right tool for any multi-file task you do often: adding an endpoint, writing a migration, scaffolding a module, reviewing a pull request. Unlike CLAUDE.md, a skill's contents are loaded only when the skill is used, so detailed templates do not cost context in every session.

Anatomy of a skill directory

An endpoint-building skill might be organized like this, with the workflow in SKILL.md, templates in assets/ and background material in references/:

.claude/skills/api-development/
├── SKILL.md                    # Workflow, trigger, steps
├── assets/
│   ├── controller-template.md
│   ├── service-template.md
│   ├── repository-template.md
│   └── dto-template.md
└── references/
    ├── nestjs-conventions.md
    └── swagger-documentation.md

The SKILL.md workflow

The file opens with a plain title:

API Development Skill

The body then states when to invoke the skill and walks through the steps in a fixed order:

## TriggerInvoke with `/api-development` when building or modifying a NestJS endpoint.## Workflow1. **Determine service & module** — check CLAUDE.md routing; ask if ambiguous.
2. **Read what exists** — entities, repositories, DTOs already in the module.
3. **Generate the DTO first** — it defines the contract. class-validator
   decorators; extend `PaginationRequestDTO` for lists.
4. **Generate the repository** — inject `DataSource`; accept an optional
   `EntityManager` for transactions; queries only, no logic.
5. **Generate the service** — inject the repository interface token; use
   `ErrorMessages` constants; throw `NotFoundException` etc.
6. **Generate the controller** — `@ApiSpec` + `@ApiOkResponse`; return
   `{ data, meta }`; no business logic.
7. **Wire the module** — add to `providers`, export by interface token.

The ordering is deliberate. Checking the routing table first stops the skill from scaffolding into the wrong service. Reading existing entities, repositories and DTOs avoids duplicating them. Generating the DTO before anything else fixes the contract, and each later layer is built against it: the repository accepts an optional EntityManager so it can join transactions, the service is injected by interface token and throws standard exceptions with ErrorMessages constants, and the controller only documents, delegates and returns { data, meta }. The final step wires providers into the module and exports them by token.

One practical note: current versions of Claude Code discover skills through YAML frontmatter at the top of SKILL.md (a name and a description that says when to use it). The example above omits it, so check the current documentation for the exact format when you create your own.

Templates should be real code

Templates in assets/ are only useful if they compile. Pseudocode invites the model to fill gaps with its own defaults. The controller template begins with the class declaration and token-based injection:

@ApiTags("bookings")
@Controller("bookings")
export class BookingController {
  constructor(
    @Inject(SERVICE_INTERFACE.BOOKING_SERVICE)
    private readonly bookingService: IBookingService,
  ) {}

It continues with a complete endpoint that shows the guard order, the permission decorator, the Swagger decorators and the return shape all at once:

  @Post()
  @UseGuards(JwtAuthGuard, PermissionsGuard)
  @Permissions([PermissionCodes.CREATE_BOOKING])
  @ApiSpec({ summary: "Create a new booking" })
  @ApiCreatedResponse({ type: CreateBookingSuccessDto })
  async createBooking(
    @Body() dto: CreateBookingDto,
    @Req() req: UserRequest,
  ): Promise<{ data: BookingEntity }> {
    const booking = await this.bookingService.createBooking(dto, req.user.id);
    return { data: booking };
  }
}

Notice what this single method teaches: JwtAuthGuard runs before PermissionsGuard, the user id comes from the typed UserRequest, the controller contains no business logic, and it returns { data } for TransformInterceptor to wrap. With a skill like this in place, the scenario described here reports producing a full endpoint (controller, service, repository and DTOs, wired into the module) in under three minutes rather than roughly twenty, in the team's style from the first attempt. Your own numbers will depend on the size of your templates and how much review you still do.

Other skills to consider

Any repeated workflow that carries team-specific conventions is a good candidate:

  • Migration generator: creates a Flyway migration in the correct schema folder using your versioning and naming scheme, together with the matching entity change.
  • Module scaffolder: produces a module file, the three layers and DI tokens, already wired.
  • Pull request review: checks a diff for N+1 queries, permission usage, layer violations and missing tests.
  • Test generator: writes *.spec.ts files that follow a service's mocking and fixture conventions.
  • Cross-service adapter: adds an IMS adapter method along with its response interface and transform helper.
  • Use-case documentation: turns a requirement into user stories with Given/When/Then acceptance criteria.

Step 5: configure permissions

.claude/settings.json decides which shell commands the assistant may run without asking first. A reasonable baseline pre-approves linting, tests and builds, and explicitly denies commands that destroy work or publish it:

{
  "permissions": {
    "allow": [
      "Bash(npm run lint:*)",
      "Bash(npm run test:*)",
      "Bash(npm run build)",
      "Bash(npx jest *)"
    ],
    "deny": ["Bash(rm -rf *)", "Bash(git push *)", "Bash(git reset --hard *)"]
  }
}

Settings come in two layers:

  • .claude/settings.json applies to the whole team and is committed.
  • .claude/settings.local.json holds personal overrides, applies only to you, and should be gitignored.

The deny list is a safety net, not a sandbox. Patterns match command strings, so treat them as a guard against accidents rather than a security boundary.

Practices that keep the setup healthy

Treat .claude/ like production code: it distributes your conventions to every engineer and every session, so it deserves review, history and pruning.

  • Keep CLAUDE.md short, around 300 lines at most. Everything in it competes for context with the code the assistant needs to read. If a fact can be derived from the code, leave it out.
  • Phrase rules as prohibitions. In practice, "never query the database inside a loop" holds up under pressure much better than "prefer bulk queries".
  • Keep templates in skills. They should load only when the skill runs, not in every session.
  • Version .claude/ in git. New rules arrive through pull requests; obsolete ones get deleted.
  • Use decision tables wherever two choices are plausible, such as DTO placement, entity location or service boundaries. In Markdown files for the assistant, a table is clearer than paragraphs.

A complete layout for the setup described in this guide:

.claude/
├── CLAUDE.md                  # Root guide (<300 lines)
├── settings.json              # Team permissions (committed)
├── rules/
│   ├── database-queries.md    # No N+1
│   ├── permissions.md         # PermissionCodes, no role checks
│   └── imports.md             # src/ alias only
└── skills/
    └── api-development/
        ├── SKILL.md
        ├── assets/            # controller/service/repository/dto templates
        └── references/        # conventions, swagger

Mistakes that quietly degrade output

Each of these produces code that looks fine while violating your conventions, which is what makes them costly.

  1. Writing CLAUDE.md as a NestJS tutorial. The model already knows the framework. Document only what is particular to you: module names, error constants, the response envelope, the permission system.
  2. Leaving out negative rules. The expensive errors are the ones that look correct; telling the assistant what not to do removes whole categories of them.
  3. Putting everything in the root file. The root file loads in every session, so service-specific depth there pollutes context when you work elsewhere.
  4. Skipping the routing table. Wrong-service placement comes from ambiguity, not ignorance, and a table makes the decision deterministic.
  5. Not documenting the adapter pattern. In services linked over HTTP, the assistant will otherwise try cross-service imports or queries against another service's database.
  6. Not mentioning synchronize: false. The model will assume TypeORM manages the schema. With Flyway in charge, an entity change without a migration fails silently, so state it explicitly.

Walkthrough: one request, before and after

Suppose a product manager asks for bookings to expose a status that consumers can see on their package.

In an unconfigured repository, the assistant reasons that packages are defined in operator-service and edits the package entity there, which is the wrong service. It hardcodes the status string and protects the change with if (user.roles.includes('admin')).

With the configuration from this guide, the request is resolved step by step:

  1. The routing table classifies a consumer-visible status as part of a consumer's interaction, so the work belongs in api-service.
  2. The cross-service rule reminds it that operator package data is read through an IMS adapter, so the field is added to the adapter's transform instead of reaching into another database.
  3. Running /api-development generates the four layers in sequence, starting from the DTO and ending with the controller.
  4. The permission rule produces @Permissions([PermissionCodes.VIEW_BOOKING_STATUS]) rather than a role check.
  5. The error-handling rule produces a NotFoundException built from an ErrorMessages constant.

A single vague sentence becomes convention-compliant changes across four files, with no clarifying questions needed.

What changes day to day

The setup takes a few hours once. The changes it is meant to produce:

  • Review focus shifts. Instead of checking every generated file for hardcoded strings, wrong imports and role checks, reviewers spend their attention on logic.
  • Endpoints get faster. Wiring four layers by hand took around twenty minutes in the described setup; the skill brings it under three.
  • Placement is right the first time. The routing table puts files in the correct service instead of review catching it late.
  • Common bugs are avoided at the source. N+1 queries and stray console.log calls are prevented during generation rather than found in pull requests.
  • Onboarding improves. New engineers no longer guess at module boundaries, because the conventions are written down and applied to their first AI-assisted feature.

Some benefits only appear over months. Code generated far apart looks consistent because it comes from the same templates and rules. Reviewers stop writing the same comments. Routing tables and rules act as documentation that is actually exercised. And a lean CLAUDE.md plus on-demand skills means the context budget goes to reading your code rather than rediscovering conventions.

Common questions

Where do the files go in a monorepo? Put the shared CLAUDE.md in .claude/ at the git root for cross-cutting concerns, and a CLAUDE.md inside each service for its specifics. The root file is always loaded; the service file is picked up when you work in that service.

How is CLAUDE.md different from rules? CLAUDE.md explains how the system works; rules state what must never appear, such as queries in loops or role-name checks.

How do skills behave? A skill such as /api-development is backed by a SKILL.md workflow plus templates and references. Invoking it makes the assistant follow a fixed, multi-step procedure instead of improvising one.

Should .claude/ be committed? Yes: CLAUDE.md, rules/, skills/ and settings.json all belong in git so the team shares one configuration. Personal overrides go in the gitignored settings.local.json.

Key takeaways

  • In a NestJS monorepo, correctness depends on conventions that no single file shows, so an assistant without context will guess.
  • Split that knowledge by type: CLAUDE.md for how things work, rules for what is forbidden, skills for repeatable procedures, settings for what may run unattended.
  • The service-ownership decision table is the highest-leverage item; write it first.
  • Document deliberate removals and non-default choices like synchronize: false, because they are exactly what a model would otherwise "fix".
  • Rules strongly steer generation but are not enforcement; keep lint, tests and CI for invariants you cannot afford to break.
  • The effort is a one-time investment of a few hours that pays back on every feature and every new hire, as long as the files are reviewed and pruned like code.