nestjs-ddd-doctor
v0.5.0
Published
Zero-dependency architecture linter for NestJS projects with DDD leanings: thin controllers, pure domain, ports over concrete infrastructure.
Downloads
955
Maintainers
Readme
nestjs-ddd-doctor 🩺
Zero-dependency architecture check-up for NestJS projects with DDD leanings — in the spirit of react-doctor, but for your API's layer boundaries.
Run it, get a check-up from the botsito 🤖, a graded report of where your architecture is leaking, and a one-keystroke handoff to your AI of choice to fix it:
$ npx nestjs-ddd-doctor
.----.
( @ @ )
\ -- / nestjs-ddd-doctor
.--'----'--. NestJS architecture check-up
/| ++ |\
d | ++ | b patient: apps/api/src
| .----. | profile: pragmatic
|__| |__|
| () |
'----'
🔴 controller-db — Controller accesses the database directly (move it to a service/repository) (6)
apps/api/src/whatsapp/bot-config.controller.ts:2
apps/api/src/whatsapp/whatsapp.controller.ts:18
...
🟠 controller-logic — Business logic in controller (dense branching — extract to a service) (1)
apps/api/src/whatsapp/whatsapp.controller.ts:43
Grade: D Score: 52/100 (🔴 4 × -10 🟠 2 × -4 🟡 0 × -1)
Needs treatment. Start with the 🔴 findings.
Fix with AI? [1] Claude Code [2] Codex [3] Copy prompt [enter] skipNo install, no dependencies, one npx. Node ≥ 20. Colored output on TTYs (respects NO_COLOR); plain text when piped. In a monorepo with no ./src it auto-detects the NestJS source dir by looking for *.controller.ts files (e.g. apps/api/src).
Usage
npx nestjs-ddd-doctor [srcDir] [options]
--profile=pragmatic|strict # default: pragmatic
--ai=claude|codex|clipboard # skip the menu, hand findings straight off
--ci # no prompts; exit 1 on any 🔴 finding
--json # machine-readable output (score, grade, findings)
--update-baseline # freeze current findings; future runs report only new onesProfiles
pragmatic (default) — for teams that want layered architecture without the full ceremony. Checks the boundaries almost every layered NestJS codebase agrees on: controllers stay thin, the domain stays pure, the application layer talks to ports.
strict — real tactical DDD, the whole thing: controllers orchestrate use cases (not services), repositories implement ports, domain entities live in domain/, persistence models in infrastructure/persistence, and every meaningful feature module has its bounded-context skeleton (domain/ + application/).
npx nestjs-ddd-doctor --profile=strictRules
Pragmatic (always on)
| Rule | Sev | What it catches |
|---|---|---|
| controller-db | 🔴 | ORM imports (drizzle-orm, typeorm, @prisma/client, mongoose, …), @InjectRepository/@InjectModel/DB tokens, or local db/schema imports inside *.controller.ts |
| controller-fetch | 🔴 | fetch( / axios. / got( inside a controller — outbound HTTP belongs in infrastructure |
| domain-purity | 🔴 | domain/ files importing @nestjs/*, any ORM, or infrastructure/ — the domain stays plain TypeScript |
| domain-imports-application | 🔴 | domain/ importing from application/ — dependencies point inward |
| controller-logic | 🟠 | More than 5 if/for/switch/while in one controller file — that's orchestration, extract a service |
| application-concrete-infra | 🟠 | application/ importing from infrastructure/ — depend on the port, not the class |
| handler-io | 🟠 | handlers/ doing direct DB/HTTP instead of using their context's repositories |
| no-forward-ref | 🟡 | forwardRef( — circular modules; rethink the dependency direction |
| raw-sql-outside-infra | 🟡 | Raw sql` /.query( outside db/, database/, infrastructure/ |
Strict (added with --profile=strict)
| Rule | Sev | What it catches |
|---|---|---|
| controller-bypasses-use-case | 🟠 | Controller constructor injecting *Service classes — in strict DDD, controllers orchestrate *UseCase/*Query/*Command |
| repository-without-port | 🟠 | class FooRepository without implements — program to the abstraction, inject via DI token |
| entity-outside-domain | 🟡 | *.entity.ts outside domain/ (ORM persistence models belong in infrastructure/persistence, mapped in the repository) |
| missing-bounded-context-layers | 🟡 | Feature module (with a controller and ≥6 files) lacking domain/ or application/ |
| fat-service | 🟡 | Service >250 lines with direct DB access — split into use cases + a repository port |
Scoring
Per rule, not per finding: penalty = weight × (1 + log₂(count)), capped (🔴 w10/cap30 · 🟠 w4/cap18 · 🟡 w1/cap8). Score = 100 − Σ penalties (floor 0). Grades: A+ ≥98 · A ≥90 · B ≥75 · C ≥60 · D ≥40 · F.
Why log damping: 200 hits of one rule are usually one systematic decision (e.g. "use cases inject the concrete repo"), not 200 independent sins — linear scoring nukes any large codebase to 0 and stops differentiating. When a single rule drives most of the penalty, the report says so explicitly.
The banner also shows structure recognition (29/41 modules DDD-shaped): the score measures boundary integrity, the structure line credits the attempt — a DDD-shaped codebase with inverted arrows scores worse than it looks, and that's the point.
AI handoff
When there are findings (and you're on a TTY), the doctor offers to hand them off:
- [1] Claude Code / [2] Codex — writes
ddd-doctor-report.md(findings + per-rule fix guidance + ground rules like "don't change behavior, keep tests green") and launches the CLI pointed at it. - [3] Copy prompt — same report, straight to your clipboard, paste it into any AI.
Non-interactive: --ai=claude, --ai=codex or --ai=clipboard. CI: --ci disables the menu entirely.
Baseline — adopt it in a legacy codebase
You don't fix 40 findings on day one. Freeze them:
npx nestjs-ddd-doctor --update-baseline # writes ddd-doctor-baseline.json — commit itFrom then on, runs auto-load the baseline and report only new findings — existing debt is tolerated, regressions fail. Counts are tracked per file::rule, so unrelated edits shifting line numbers don't break it. Shrink the baseline as you pay debt down (re-run --update-baseline after fixing).
GitHub Action
Fail PRs that introduce new architecture violations:
# .github/workflows/architecture.yml
name: architecture
on: [pull_request]
jobs:
ddd-doctor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- uses: lucasscurtoo/nestjs-ddd-doctor@main
with:
src-dir: apps/api/src # optional — auto-detected
profile: pragmatic # or strictCombined with a committed baseline, this becomes a free architecture guard-rail: existing debt passes, new leaks fail the check.
JSON output
npx nestjs-ddd-doctor --json{
"src": "apps/api/src",
"profile": "pragmatic",
"score": 62,
"grade": "C",
"counts": { "high": 3, "med": 1, "low": 2 },
"baselined": 17,
"findings": [
{ "rule": "controller-db", "severity": "high", "file": "src/foo.controller.ts", "line": 12 }
]
}Config
./ddd-doctor.config.json:
{
"profile": "strict",
"rules": { "controller-logic": "off" },
"exempt": ["health.controller.ts", "lock.service.ts"]
}exempt entries are path suffixes; a matching file is skipped by every rule. For a single justified line:
// ddd-doctor-disable-next-line
const rows = await this.db.execute(sql`select 1`);Philosophy
- Heuristic, not a compiler. Plain regex over your source. Fast, zero deps, occasionally wrong — that's what the escape hatch is for.
- Boundaries, not dogma. Pragmatic mode doesn't demand aggregates or CQRS; strict mode is there when you do want the whole tactical toolbox — and the grade tells you honestly how far you are.
- Convention-driven. Layers are detected by folder name (
domain/,application/,infrastructure/,handlers/,db/). If your project doesn't use a folder, its rules simply never fire.
License
MIT
