@tndhuy/create-app 1.1.4 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -40,6 +40,7 @@ function toPascalCase(kebab) {
40
40
  function buildReplacements(serviceName) {
41
41
  return [
42
42
  ["nestjs-backend-template", serviceName],
43
+ ["{{SERVICE_NAME_KEBAB}}", serviceName],
43
44
  ["NestjsBackendTemplate", toPascalCase(serviceName)]
44
45
  ];
45
46
  }
@@ -158,18 +159,60 @@ async function collectPaths(dir) {
158
159
  }
159
160
  return results;
160
161
  }
161
- async function replaceFileContents(dir, replacements) {
162
+ async function replaceFileContents(dir, replacements, options) {
162
163
  const entries = await (0, import_promises2.readdir)(dir, { withFileTypes: true });
163
164
  for (const entry of entries) {
164
165
  const fullPath = (0, import_path2.join)(dir, entry.name);
165
166
  if (entry.isDirectory()) {
166
- await replaceFileContents(fullPath, replacements);
167
+ await replaceFileContents(fullPath, replacements, options);
167
168
  } else {
168
169
  const binary = await isBinaryFile(fullPath);
169
170
  if (binary) continue;
170
171
  try {
171
172
  let content = await (0, import_promises2.readFile)(fullPath, "utf-8");
172
173
  let modified = false;
174
+ const isPrisma = options.orm === "prisma";
175
+ const isMongoose = options.orm === "mongoose";
176
+ const hasRedis = options.modules.includes("redis");
177
+ const hasOtel = options.modules.includes("otel");
178
+ const hasKafka = options.modules.includes("kafka");
179
+ const isPostgres = options.db === "postgres";
180
+ const isMongo = options.db === "mongo";
181
+ const checkBlock = (content2, flag, tag) => {
182
+ const startTag = `{{#IF_${tag}}}`;
183
+ const endTag = `{{/IF_${tag}}}`;
184
+ const startNotTag = `{{#IF_NOT_${tag}}}`;
185
+ const endNotTag = `{{/IF_NOT_${tag}}}`;
186
+ if (content2.includes(startTag)) {
187
+ if (flag) {
188
+ content2 = content2.replaceAll(startTag, "").replaceAll(endTag, "");
189
+ } else {
190
+ const escapedStart = startTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
191
+ const escapedEnd = endTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
192
+ const regex = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, "g");
193
+ content2 = content2.replace(regex, "");
194
+ }
195
+ }
196
+ if (content2.includes(startNotTag)) {
197
+ if (!flag) {
198
+ content2 = content2.replaceAll(startNotTag, "").replaceAll(endNotTag, "");
199
+ } else {
200
+ const escapedStart = startNotTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
201
+ const escapedEnd = endNotTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
202
+ const regex = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, "g");
203
+ content2 = content2.replace(regex, "");
204
+ }
205
+ }
206
+ return content2;
207
+ };
208
+ content = checkBlock(content, isPrisma, "PRISMA");
209
+ content = checkBlock(content, isMongoose, "MONGOOSE");
210
+ content = checkBlock(content, hasRedis, "REDIS");
211
+ content = checkBlock(content, hasOtel, "OTEL");
212
+ content = checkBlock(content, hasKafka, "KAFKA");
213
+ content = checkBlock(content, isPostgres, "POSTGRES");
214
+ content = checkBlock(content, isMongo, "MONGO");
215
+ modified = content !== await (0, import_promises2.readFile)(fullPath, "utf-8");
173
216
  for (const [from, to] of replacements) {
174
217
  if (content.includes(from)) {
175
218
  content = content.replaceAll(from, to);
@@ -216,8 +259,21 @@ async function patchPackageJson(destDir, options) {
216
259
  const raw = await (0, import_promises2.readFile)(pkgPath, "utf-8");
217
260
  const pkg = JSON.parse(raw);
218
261
  pkg.name = options.serviceName;
262
+ delete pkg.workspaces;
219
263
  const PRISMA_LATEST = "^7.5.0";
220
264
  const PRISMA_MONGO_COMPAT = "^6.0.0";
265
+ if (options.orm === "prisma") {
266
+ if (!pkg.scripts) pkg.scripts = {};
267
+ pkg.scripts["db:generate"] = "prisma generate";
268
+ pkg.scripts["db:push"] = "prisma db push";
269
+ pkg.scripts["db:pull"] = "prisma db pull";
270
+ pkg.scripts["db:studio"] = "prisma studio";
271
+ pkg.scripts["db:format"] = "prisma format";
272
+ if (options.db === "postgres") {
273
+ pkg.scripts["db:migrate:dev"] = "prisma migrate dev";
274
+ pkg.scripts["db:migrate:deploy"] = "prisma migrate deploy";
275
+ }
276
+ }
221
277
  if (options.db === "mongo") {
222
278
  if (options.orm === "prisma") {
223
279
  if (pkg.dependencies) {
@@ -393,8 +449,11 @@ async function removeOtel(destDir) {
393
449
  // Remove: import otelSdk from './instrumentation';
394
450
  /^import\s+\w+\s+from\s+['"][^'"]*instrumentation['"];\n?/m,
395
451
  // Remove: otelSdk?.start(); line
396
- /^\s*\w+Sdk\?\.start\(\);\n?/m
452
+ /^\s*\w+Sdk\?\.start\(\);\n?/m,
453
+ // Remove shutdown logic: void otelSdk?.shutdown()...
454
+ /^\s*void\s+otelSdk\?\.shutdown\(\)\.catch\(\(\)\s*=>\s*undefined\);\n?/m
397
455
  ]);
456
+ await safeDeleteFile(destDir, (0, import_path2.join)(destDir, "prometheus.yml"));
398
457
  }
399
458
  async function addKafka(destDir, serviceName) {
400
459
  await generateKafkaModule(destDir, serviceName);
@@ -464,7 +523,7 @@ async function scaffold(options) {
464
523
  }
465
524
  await (0, import_promises2.cp)(templateDir, destDir, { recursive: true });
466
525
  const replacements = buildReplacements(serviceName);
467
- await replaceFileContents(destDir, replacements);
526
+ await replaceFileContents(destDir, replacements, options);
468
527
  await renamePathsWithPlaceholders(destDir, replacements);
469
528
  await patchPackageJson(destDir, options);
470
529
  if (options.db === "mongo" && options.orm === "prisma") {
@@ -504,6 +563,10 @@ async function scaffold(options) {
504
563
  if (modules.includes("kafka")) {
505
564
  await addKafka(destDir, serviceName);
506
565
  }
566
+ const templateGitignore = (0, import_path2.join)(destDir, "gitignore.template");
567
+ if (await pathExists(templateGitignore)) {
568
+ await (0, import_promises2.rename)(templateGitignore, (0, import_path2.join)(destDir, ".gitignore"));
569
+ }
507
570
  }
508
571
 
509
572
  // src/cli.ts
@@ -535,6 +598,7 @@ async function main() {
535
598
  };
536
599
  if (positionalName && !validateServiceName(positionalName)) {
537
600
  config.serviceName = positionalName;
601
+ (0, import_prompts.note)(`Using service name: ${config.serviceName} (from arguments)`, "Info");
538
602
  }
539
603
  let step = config.serviceName ? 1 : 0;
540
604
  const totalSteps = 4;
@@ -697,8 +761,9 @@ Modules : ${selectedModules}`,
697
761
  }
698
762
  }
699
763
  const installDeps = guardCancel(await (0, import_prompts.confirm)({ message: "Install dependencies now?", initialValue: true }));
764
+ let pkgManager = "npm";
700
765
  if (installDeps) {
701
- const pkgManager = guardCancel(await (0, import_prompts.select)({
766
+ pkgManager = guardCancel(await (0, import_prompts.select)({
702
767
  message: "Select package manager",
703
768
  options: [
704
769
  { value: "pnpm", label: "pnpm", hint: "recommended" },
@@ -711,17 +776,28 @@ Modules : ${selectedModules}`,
711
776
  try {
712
777
  await (0, import_execa.execa)(pkgManager, ["install"], { cwd: destDir, stdio: "inherit" });
713
778
  is.stop("Dependencies installed successfully.");
779
+ if (config.orm === "prisma") {
780
+ const ps = (0, import_prompts.spinner)();
781
+ ps.start("Generating Prisma Client...");
782
+ try {
783
+ await (0, import_execa.execa)(pkgManager, ["run", "db:generate"], { cwd: destDir });
784
+ ps.stop("Prisma Client generated successfully.");
785
+ } catch (err) {
786
+ ps.stop("Prisma Client generation failed. You may need to run it manually.");
787
+ }
788
+ }
714
789
  } catch (err) {
715
790
  is.stop("Dependency installation failed.");
716
791
  }
717
792
  }
718
- }
719
- (0, import_prompts.outro)(
720
- `Next steps:
793
+ (0, import_prompts.outro)(
794
+ `Next steps:
721
795
 
722
796
  cd ${config.serviceName}
723
- ${dryRun ? "" : " npm run start:dev\n"}`
724
- );
797
+ ${dryRun ? "" : ` ${pkgManager} run start:dev
798
+ `}`
799
+ );
800
+ }
725
801
  }
726
802
  main().catch((err) => {
727
803
  (0, import_prompts.cancel)(err instanceof Error ? err.message : String(err));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tndhuy/create-app",
3
- "version": "1.1.4",
3
+ "version": "1.2.3",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "create-app": "dist/cli.js"
@@ -0,0 +1,128 @@
1
+ # nestjs-backend-template
2
+
3
+ ## Overview
4
+
5
+ - Production-ready NestJS 11 template with Domain-Driven Design (DDD) architecture — four layers (Domain, Application, Infrastructure, Presenter) with CQRS via `@nestjs/cqrs`
6
+ - Pre-configured infrastructure: Prisma ORM, ioredis with circuit breaker, OpenTelemetry tracing and Prometheus metrics, Scalar API docs at `/docs`
7
+ - Agent-ready: Claude Code (`.claude/`) and Antigravity (`.agent/`) configs pre-wired with codebase context files and GSD skill
8
+
9
+ ## Quick Start
10
+
11
+ 1. **Clone the repository**
12
+
13
+ ```bash
14
+ git clone <repo-url> my-service && cd my-service
15
+ ```
16
+
17
+ 2. **Install dependencies**
18
+
19
+ ```bash
20
+ pnpm install
21
+ ```
22
+
23
+ 3. **Configure environment**
24
+
25
+ ```bash
26
+ cp .env.example .env
27
+ ```
28
+
29
+ Edit `.env` and set at minimum:
30
+ - `DATABASE_URL` — MongoDB connection string
31
+ - `REDIS_URL` — Redis connection string
32
+
33
+ 4. **Start infrastructure**
34
+
35
+ ```bash
36
+ docker compose up -d
37
+ ```
38
+
39
+ Starts MongoDB and Redis locally.
40
+
41
+ 5. **Start the server**
42
+
43
+ ```bash
44
+ pnpm dev
45
+ ```
46
+
47
+ Server starts at http://localhost:3000. API docs available at http://localhost:3000/docs (login: `admin` / `admin`).
48
+
49
+ ## Available Scripts
50
+
51
+ | Script | Description |
52
+ |--------|-------------|
53
+ | `pnpm start:dev` | Start in watch mode (development) |
54
+ | `pnpm build` | Compile TypeScript to `dist/` |
55
+ | `pnpm start:prod` | Run compiled build (`dist/src/main`) |
56
+ | `pnpm test` | Run unit tests with Jest |
57
+ | `pnpm test:cov` | Run tests with coverage report |
58
+ | `pnpm test:e2e` | Run end-to-end tests |
59
+ | `pnpm lint` | Run ESLint with auto-fix |
60
+ | `pnpm format` | Run Prettier on `src/` and `test/` |
61
+ | `pnpm db:generate` | Generate Prisma Client from schema |
62
+ | `pnpm db:sync` | Sync Prisma schema to MongoDB (`prisma db push`) |
63
+ | `pnpm db:sync:force` | Force sync schema with destructive changes (`--accept-data-loss`) |
64
+ | `pnpm db:push` | Push schema changes directly to database (no migration files) |
65
+ | `pnpm db:pull` | Pull database schema into `prisma/schema/` |
66
+ | `pnpm db:validate` | Validate Prisma schema and datasource config |
67
+ | `pnpm db:format` | Format Prisma schema files under `prisma/schema/` |
68
+ | `pnpm db:studio` | Open Prisma Studio for data browsing/editing |
69
+
70
+ ## MongoDB Schema Workflow
71
+
72
+ Prisma migrations are not used for MongoDB in this template.
73
+
74
+ 1. Edit schema files in `prisma/schema/`
75
+ 2. Run `pnpm db:format`
76
+ 3. Run `pnpm db:validate`
77
+ 4. Run `pnpm db:sync` (or `pnpm db:sync:force` when explicitly needed)
78
+ 5. Run `pnpm db:generate`
79
+
80
+ ## Environment Variables
81
+
82
+ | Variable | Required | Description | Default |
83
+ |----------|----------|-------------|---------|
84
+ | `PORT` | No | HTTP server port | `3000` |
85
+ | `NODE_ENV` | No | Environment (`development` / `production`) | `development` |
86
+ | `DATABASE_URL` | Yes | MongoDB connection string | — |
87
+ | `REDIS_URL` | Yes | Redis connection string | — |
88
+ | `API_VERSION` | No | API version for URI prefix | `1` |
89
+ | `APP_NAME` | No | Application name shown in Scalar docs | `NestJS Backend Template` |
90
+ | `APP_DESCRIPTION` | No | API description shown in Scalar docs | `API Documentation` |
91
+ | `REQUEST_TIMEOUT` | No | Request timeout in milliseconds | `30000` |
92
+ | `THROTTLE_TTL` | No | Rate limit window in seconds | `60` |
93
+ | `THROTTLE_LIMIT` | No | Max requests per window | `100` |
94
+ | `DOCS_USER` | No | Basic auth username for `/docs` | `admin` |
95
+ | `DOCS_PASS` | No | Basic auth password for `/docs` | `admin` |
96
+ | `OTEL_ENABLED` | No | Enable OpenTelemetry tracing and metrics | `false` |
97
+ | `OTEL_SERVICE_NAME` | No | Service name reported to OTel collector | `nestjs-backend-template` |
98
+ | `OTEL_PROMETHEUS_PORT` | No | Port for Prometheus metrics scrape endpoint | `9464` |
99
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | No | OTLP HTTP endpoint for trace export | `http://localhost:4318` |
100
+ | `LARK_APP_ID` | No | Lark app ID for MCP integration (agent use) | — |
101
+ | `LARK_APP_SECRET` | No | Lark app secret for MCP integration (agent use) | — |
102
+
103
+ ## Architecture
104
+
105
+ This template follows Domain-Driven Design (DDD) with four layers: Domain, Application, Infrastructure, and Presenter. Each domain module is fully self-contained under `src/<module>/` and communicates through the CQRS bus.
106
+
107
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full guide including layer rules, data flow, and the `ExampleModule` walkthrough.
108
+
109
+ ## Adding a New Module
110
+
111
+ Each new domain module follows the same DDD structure as `src/example/`. Follow the step-by-step walkthrough in [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md).
112
+
113
+ ## Tech Stack
114
+
115
+ - **NestJS** 11.0.1 — framework (Express 5 platform)
116
+ - **Prisma** 7.5.0 — MongoDB ORM with schema sync (`db push`)
117
+ - **ioredis** 5.10.1 — Redis client with cockatiel circuit breaker
118
+ - **Pino** / nestjs-pino — structured JSON logging
119
+ - **OpenTelemetry** SDK — distributed tracing (OTLP) and Prometheus metrics
120
+ - **Scalar** (@scalar/nestjs-api-reference) — interactive API docs
121
+ - **class-validator** / **class-transformer** — DTO validation and transformation
122
+ - **@nestjs/cqrs** 11.0.3 — command and query bus
123
+ - **@nestjs/throttler** 6.5.0 — global rate limiting
124
+ - **TypeScript** 5.7.3
125
+
126
+ ## License
127
+
128
+ UNLICENSED
@@ -1,7 +1,28 @@
1
1
  services:
2
+ {{#IF_POSTGRES}}
3
+ postgres:
4
+ image: postgres:16-alpine
5
+ container_name: {{SERVICE_NAME_KEBAB}}-postgres
6
+ restart: unless-stopped
7
+ ports:
8
+ - "5432:5432"
9
+ environment:
10
+ POSTGRES_USER: user
11
+ POSTGRES_PASSWORD: password
12
+ POSTGRES_DB: template_db
13
+ volumes:
14
+ - postgres_data:/var/lib/postgresql/data
15
+ healthcheck:
16
+ test: ["CMD-SHELL", "pg_isready -U user -d template_db"]
17
+ interval: 10s
18
+ timeout: 5s
19
+ retries: 5
20
+ {{/IF_POSTGRES}}
21
+
22
+ {{#IF_MONGO}}
2
23
  mongodb:
3
24
  image: mongo:latest
4
- container_name: nestjs-template-mongodb
25
+ container_name: {{SERVICE_NAME_KEBAB}}-mongodb
5
26
  restart: unless-stopped
6
27
  ports:
7
28
  - "27017:27017"
@@ -13,11 +34,13 @@ services:
13
34
  test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
14
35
  interval: 10s
15
36
  timeout: 5s
16
- retries: 10
37
+ retries: 5
38
+ {{/IF_MONGO}}
17
39
 
40
+ {{#IF_REDIS}}
18
41
  redis:
19
42
  image: redis:latest
20
- container_name: nestjs-template-redis
43
+ container_name: {{SERVICE_NAME_KEBAB}}-redis
21
44
  restart: unless-stopped
22
45
  ports:
23
46
  - "6379:6379"
@@ -28,8 +51,57 @@ services:
28
51
  test: ["CMD", "redis-cli", "ping"]
29
52
  interval: 10s
30
53
  timeout: 5s
31
- retries: 10
54
+ retries: 5
55
+ {{/IF_REDIS}}
56
+
57
+ {{#IF_KAFKA}}
58
+ zookeeper:
59
+ image: bitnami/zookeeper:latest
60
+ container_name: {{SERVICE_NAME_KEBAB}}-zookeeper
61
+ ports:
62
+ - "2181:2181"
63
+ environment:
64
+ - ALLOW_ANONYMOUS_LOGIN=yes
65
+
66
+ kafka:
67
+ image: bitnami/kafka:latest
68
+ container_name: {{SERVICE_NAME_KEBAB}}-kafka
69
+ ports:
70
+ - "9092:9092"
71
+ environment:
72
+ - KAFKA_BROKER_ID=1
73
+ - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181
74
+ - ALLOW_PLAINTEXT_LISTENER=yes
75
+ - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092
76
+ depends_on:
77
+ - zookeeper
78
+ {{/IF_KAFKA}}
79
+
80
+ {{#IF_OTEL}}
81
+ jaeger:
82
+ image: jaegertracing/all-in-one:latest
83
+ container_name: {{SERVICE_NAME_KEBAB}}-jaeger
84
+ ports:
85
+ - "16686:16686"
86
+ - "4317:4317"
87
+ - "4318:4318"
88
+
89
+ prometheus:
90
+ image: prom/prometheus:latest
91
+ container_name: {{SERVICE_NAME_KEBAB}}-prometheus
92
+ ports:
93
+ - "9090:9090"
94
+ volumes:
95
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml
96
+ {{/IF_OTEL}}
32
97
 
33
98
  volumes:
99
+ {{#IF_POSTGRES}}
100
+ postgres_data:
101
+ {{/IF_POSTGRES}}
102
+ {{#IF_MONGO}}
34
103
  mongodb_data:
104
+ {{/IF_MONGO}}
105
+ {{#IF_REDIS}}
35
106
  redis_data:
107
+ {{/IF_REDIS}}
@@ -0,0 +1,51 @@
1
+ # compiled output
2
+ /dist
3
+ /node_modules
4
+ /build
5
+
6
+ # Logs
7
+ logs
8
+ *.log
9
+ npm-debug.log*
10
+ pnpm-debug.log*
11
+ yarn-debug.log*
12
+ yarn-error.log*
13
+ lerna-debug.log*
14
+
15
+ # OS
16
+ .DS_Store
17
+
18
+ # Tests
19
+ /coverage
20
+ /.nyc_output
21
+
22
+ # IDEs and editors
23
+ /.idea
24
+ .project
25
+ .classpath
26
+ .c9/
27
+ *.launch
28
+ .settings/
29
+ *.sublime-workspace
30
+
31
+ # IDE - VSCode
32
+ .vscode/*
33
+ !.vscode/settings.json
34
+ !.vscode/tasks.json
35
+ !.vscode/launch.json
36
+ !.vscode/extensions.json
37
+
38
+ # Environment files
39
+ .env
40
+ .env.development.local
41
+ .env.test.local
42
+ .env.production.local
43
+ .env.local
44
+
45
+ # TypeScript incremental compilation
46
+ *.tsbuildinfo
47
+
48
+ # Claude Code memory files
49
+ **/.claude/
50
+ **/CLAUDE.md
51
+ !/CLAUDE.md
@@ -0,0 +1,7 @@
1
+ global:
2
+ scrape_interval: 15s
3
+
4
+ scrape_configs:
5
+ - job_name: 'nestjs-service'
6
+ static_configs:
7
+ - targets: ['host.docker.internal:9464']
@@ -4,11 +4,22 @@ import { Observable } from 'rxjs';
4
4
  import { map } from 'rxjs/operators';
5
5
  import { PUBLIC_API_KEY } from '../decorators/public-api.decorator';
6
6
 
7
+ export const RAW_RESPONSE_KEY = 'raw_response';
8
+
7
9
  @Injectable()
8
10
  export class TransformInterceptor<T> implements NestInterceptor<T, unknown> {
9
11
  constructor(private readonly reflector: Reflector) {}
10
12
 
11
13
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
14
+ const isRaw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE_KEY, [
15
+ context.getHandler(),
16
+ context.getClass(),
17
+ ]);
18
+
19
+ if (isRaw) {
20
+ return next.handle();
21
+ }
22
+
12
23
  const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_API_KEY, [
13
24
  context.getHandler(),
14
25
  context.getClass(),
@@ -1,9 +1,11 @@
1
1
  import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
2
2
  import Redis from 'ioredis';
3
3
  import { circuitBreaker, ConsecutiveBreaker, CircuitBreakerPolicy, handleAll } from 'cockatiel';
4
+ {{#IF_OTEL}}
4
5
  import { trace } from '@opentelemetry/api';
5
6
 
6
7
  const tracer = trace.getTracer('redis-service');
8
+ {{/IF_OTEL}}
7
9
 
8
10
  @Injectable()
9
11
  export class RedisService implements OnModuleDestroy {
@@ -46,6 +48,7 @@ export class RedisService implements OnModuleDestroy {
46
48
  }
47
49
 
48
50
  async get(key: string): Promise<string | null> {
51
+ {{#IF_OTEL}}
49
52
  return tracer.startActiveSpan('redis.get', async (span) => {
50
53
  try {
51
54
  span.setAttribute('db.system', 'redis');
@@ -59,9 +62,14 @@ export class RedisService implements OnModuleDestroy {
59
62
  span.end();
60
63
  }
61
64
  });
65
+ {{/IF_OTEL}}
66
+ {{#IF_NOT_OTEL}}
67
+ return this.execute((c) => c.get(key));
68
+ {{/IF_NOT_OTEL}}
62
69
  }
63
70
 
64
71
  async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
72
+ {{#IF_OTEL}}
65
73
  return tracer.startActiveSpan('redis.set', async (span) => {
66
74
  try {
67
75
  span.setAttribute('db.system', 'redis');
@@ -84,9 +92,20 @@ export class RedisService implements OnModuleDestroy {
84
92
  span.end();
85
93
  }
86
94
  });
95
+ {{/IF_OTEL}}
96
+ {{#IF_NOT_OTEL}}
97
+ await this.execute(async (c) => {
98
+ if (ttlSeconds) {
99
+ await c.set(key, value, 'EX', ttlSeconds);
100
+ } else {
101
+ await c.set(key, value);
102
+ }
103
+ });
104
+ {{/IF_NOT_OTEL}}
87
105
  }
88
106
 
89
107
  async del(key: string): Promise<void> {
108
+ {{#IF_OTEL}}
90
109
  return tracer.startActiveSpan('redis.del', async (span) => {
91
110
  try {
92
111
  span.setAttribute('db.system', 'redis');
@@ -100,9 +119,14 @@ export class RedisService implements OnModuleDestroy {
100
119
  span.end();
101
120
  }
102
121
  });
122
+ {{/IF_OTEL}}
123
+ {{#IF_NOT_OTEL}}
124
+ await this.execute((c) => c.del(key).then(() => undefined));
125
+ {{/IF_NOT_OTEL}}
103
126
  }
104
127
 
105
128
  async expire(key: string, ttlSeconds: number): Promise<void> {
129
+ {{#IF_OTEL}}
106
130
  return tracer.startActiveSpan('redis.expire', async (span) => {
107
131
  try {
108
132
  span.setAttribute('db.system', 'redis');
@@ -117,5 +141,9 @@ export class RedisService implements OnModuleDestroy {
117
141
  span.end();
118
142
  }
119
143
  });
144
+ {{/IF_OTEL}}
145
+ {{#IF_NOT_OTEL}}
146
+ await this.execute((c) => c.expire(key, ttlSeconds).then(() => undefined));
147
+ {{/IF_NOT_OTEL}}
120
148
  }
121
149
  }
@@ -84,10 +84,19 @@ async function bootstrap() {
84
84
  const port = parseInt(process.env.PORT ?? '3000', 10);
85
85
  const httpServer = await app.listen(port);
86
86
 
87
+ const logger = app.get(Logger);
88
+ logger.log(`🚀 Application is running on: http://localhost:${port}`);
89
+ logger.log(`📖 API Documentation (Scalar): http://localhost:${port}/docs`);
90
+ logger.log(`📄 API Spec (JSON): http://localhost:${port}/docs/json`);
91
+
87
92
  process.on('SIGTERM', () => {
88
93
  loggerService.log('SIGTERM signal received: closing HTTP server');
89
94
  setTimeout(() => {
90
- void otelSdk?.shutdown().catch(() => undefined);
95
+ if (process.env.OTEL_ENABLED === 'true') {
96
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
97
+ const otelSdk = require('./instrumentation').default;
98
+ void otelSdk?.shutdown().catch(() => undefined);
99
+ }
91
100
  httpServer.close(() => {
92
101
  loggerService.log('HTTP server closed');
93
102
  process.exit(0);
@@ -1,6 +1,8 @@
1
1
  import { join } from 'path';
2
2
  import type { Params } from 'nestjs-pino';
3
+ {{#IF_OTEL}}
3
4
  import { trace, context, isSpanContextValid } from '@opentelemetry/api';
5
+ {{/IF_OTEL}}
4
6
 
5
7
  const isDev = process.env.NODE_ENV !== 'production';
6
8
  const LOG_DIR = join(process.cwd(), 'logs');
@@ -11,6 +13,7 @@ export const pinoConfig: Params = {
11
13
  level: isDev ? 'debug' : 'info',
12
14
  // Inject Trace context into every log line
13
15
  mixin() {
16
+ {{#IF_OTEL}}
14
17
  const activeSpan = trace.getSpan(context.active());
15
18
  if (activeSpan) {
16
19
  const spanContext = activeSpan.spanContext();
@@ -22,6 +25,7 @@ export const pinoConfig: Params = {
22
25
  };
23
26
  }
24
27
  }
28
+ {{/IF_OTEL}}
25
29
  return {};
26
30
  },
27
31
  // Standardize on X-Request-Id as the primary req.id
@@ -0,0 +1,76 @@
1
+ # {{SERVICE_NAME_KEBAB}}
2
+
3
+ Production-ready NestJS 11 backend service based on DDD architecture.
4
+
5
+ ## Architecture Overview
6
+
7
+ This project follows **Domain-Driven Design (DDD)** principles with four distinct layers:
8
+
9
+ 1. **Domain:** Core logic, Entities, Value Objects, and Repository interfaces. (No framework dependencies)
10
+ 2. **Application:** Command/Query handlers (CQRS), DTOs, and application services.
11
+ 3. **Infrastructure:** Database implementations (Prisma/Mongoose), Redis cache, and external integrations.
12
+ 4. **Presenter:** Controllers (HTTP/RPC), Interceptors, and Filters.
13
+
14
+ ## Features
15
+
16
+ - **Pino Logging:** High-performance logging with request context tracking.
17
+ - **Request ID:** Every request is assigned a unique `X-Request-Id` header for traceability.
18
+ - **OpenTelemetry:** Distributed tracing and Prometheus metrics (if enabled).
19
+ - **Circuit Breaker:** Resilience for Redis and external calls using Cockatiel.
20
+ - **API Reference:** Interactive Scalar documentation available at `/docs`.
21
+
22
+ ## Quick Start
23
+
24
+ ### 1. Configure Environment
25
+
26
+ ```bash
27
+ cp .env.example .env
28
+ ```
29
+
30
+ ### 2. Infrastructure
31
+
32
+ Start database and cache using Docker:
33
+
34
+ ```bash
35
+ docker compose up -d
36
+ ```
37
+
38
+ ### 3. Database Setup
39
+
40
+ {{#IF_PRISMA}}
41
+ ```bash
42
+ # Generate Prisma Client
43
+ npm run db:generate
44
+
45
+ # Apply migrations
46
+ npm run db:migrate:deploy
47
+ ```
48
+ {{/IF_PRISMA}}
49
+ {{#IF_MONGOOSE}}
50
+ Ensure your `MONGODB_URL` is correct in `.env`.
51
+ {{/IF_MONGOOSE}}
52
+
53
+ ### 4. Running the App
54
+
55
+ ```bash
56
+ # Development
57
+ npm run start:dev
58
+
59
+ # Production build
60
+ npm run build
61
+ npm run start:prod
62
+ ```
63
+
64
+ ## Logging & Observability
65
+
66
+ - **Console:** Pretty-printed logs in development mode.
67
+ - **Files:** Logs are automatically rotated and stored in the `logs/` directory.
68
+ - **Correlation:** Search for `requestId` in logs to trace all logs for a specific user request.
69
+
70
+ ## Available Scripts
71
+
72
+ - `npm run build`: Compile the project.
73
+ - `npm run start:dev`: Start in watch mode.
74
+ - `npm run test`: Run unit tests.
75
+ - `npm run test:e2e`: Run end-to-end tests.
76
+ - `npm run lint`: Fix code style issues.
@@ -0,0 +1,107 @@
1
+ services:
2
+ {{#IF_POSTGRES}}
3
+ postgres:
4
+ image: postgres:16-alpine
5
+ container_name: {{SERVICE_NAME_KEBAB}}-postgres
6
+ restart: unless-stopped
7
+ ports:
8
+ - "5432:5432"
9
+ environment:
10
+ POSTGRES_USER: user
11
+ POSTGRES_PASSWORD: password
12
+ POSTGRES_DB: template_db
13
+ volumes:
14
+ - postgres_data:/var/lib/postgresql/data
15
+ healthcheck:
16
+ test: ["CMD-SHELL", "pg_isready -U user -d template_db"]
17
+ interval: 10s
18
+ timeout: 5s
19
+ retries: 5
20
+ {{/IF_POSTGRES}}
21
+
22
+ {{#IF_MONGO}}
23
+ mongodb:
24
+ image: mongo:latest
25
+ container_name: {{SERVICE_NAME_KEBAB}}-mongodb
26
+ restart: unless-stopped
27
+ ports:
28
+ - "27017:27017"
29
+ environment:
30
+ MONGO_INITDB_DATABASE: template_db
31
+ volumes:
32
+ - mongodb_data:/data/db
33
+ healthcheck:
34
+ test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
35
+ interval: 10s
36
+ timeout: 5s
37
+ retries: 5
38
+ {{/IF_MONGO}}
39
+
40
+ {{#IF_REDIS}}
41
+ redis:
42
+ image: redis:latest
43
+ container_name: {{SERVICE_NAME_KEBAB}}-redis
44
+ restart: unless-stopped
45
+ ports:
46
+ - "6379:6379"
47
+ command: ["redis-server", "--appendonly", "yes"]
48
+ volumes:
49
+ - redis_data:/data
50
+ healthcheck:
51
+ test: ["CMD", "redis-cli", "ping"]
52
+ interval: 10s
53
+ timeout: 5s
54
+ retries: 5
55
+ {{/IF_REDIS}}
56
+
57
+ {{#IF_KAFKA}}
58
+ zookeeper:
59
+ image: bitnami/zookeeper:latest
60
+ container_name: {{SERVICE_NAME_KEBAB}}-zookeeper
61
+ ports:
62
+ - "2181:2181"
63
+ environment:
64
+ - ALLOW_ANONYMOUS_LOGIN=yes
65
+
66
+ kafka:
67
+ image: bitnami/kafka:latest
68
+ container_name: {{SERVICE_NAME_KEBAB}}-kafka
69
+ ports:
70
+ - "9092:9092"
71
+ environment:
72
+ - KAFKA_BROKER_ID=1
73
+ - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181
74
+ - ALLOW_PLAINTEXT_LISTENER=yes
75
+ - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092
76
+ depends_on:
77
+ - zookeeper
78
+ {{/IF_KAFKA}}
79
+
80
+ {{#IF_OTEL}}
81
+ jaeger:
82
+ image: jaegertracing/all-in-one:latest
83
+ container_name: {{SERVICE_NAME_KEBAB}}-jaeger
84
+ ports:
85
+ - "16686:16686"
86
+ - "4317:4317"
87
+ - "4318:4318"
88
+
89
+ prometheus:
90
+ image: prom/prometheus:latest
91
+ container_name: {{SERVICE_NAME_KEBAB}}-prometheus
92
+ ports:
93
+ - "9090:9090"
94
+ volumes:
95
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml
96
+ {{/IF_OTEL}}
97
+
98
+ volumes:
99
+ {{#IF_POSTGRES}}
100
+ postgres_data:
101
+ {{/IF_POSTGRES}}
102
+ {{#IF_MONGO}}
103
+ mongodb_data:
104
+ {{/IF_MONGO}}
105
+ {{#IF_REDIS}}
106
+ redis_data:
107
+ {{/IF_REDIS}}
@@ -0,0 +1,51 @@
1
+ # compiled output
2
+ dist/
3
+ node_modules/
4
+ /build
5
+
6
+ # Logs
7
+ logs
8
+ *.log
9
+ npm-debug.log*
10
+ pnpm-debug.log*
11
+ yarn-debug.log*
12
+ yarn-error.log*
13
+ lerna-debug.log*
14
+
15
+ # OS
16
+ .DS_Store
17
+
18
+ # Tests
19
+ /coverage
20
+ /.nyc_output
21
+
22
+ # IDEs and editors
23
+ /.idea
24
+ .project
25
+ .classpath
26
+ .c9/
27
+ *.launch
28
+ .settings/
29
+ *.sublime-workspace
30
+
31
+ # IDE - VSCode
32
+ .vscode/*
33
+ !.vscode/settings.json
34
+ !.vscode/tasks.json
35
+ !.vscode/launch.json
36
+ !.vscode/extensions.json
37
+
38
+ # Environment files
39
+ .env
40
+ .env.development.local
41
+ .env.test.local
42
+ .env.production.local
43
+ .env.local
44
+
45
+ # TypeScript incremental compilation
46
+ *.tsbuildinfo
47
+
48
+ # Do not add .npmrc files with auth tokens
49
+ # packages/create-app/.npmrc is committed intentionally (uses ${NODE_AUTH_TOKEN} placeholder, no literal token)
50
+ .npmrc
51
+ !packages/create-app/.npmrc
@@ -4,7 +4,7 @@
4
4
  "description": "",
5
5
  "author": "",
6
6
  "private": true,
7
- "license": "UNLICENSED",
7
+ "license": "MIT",
8
8
  "workspaces": [
9
9
  "packages/*"
10
10
  ],
@@ -63,7 +63,6 @@
63
63
  "@nestjs/schematics": "^11.0.0",
64
64
  "@nestjs/testing": "^11.0.1",
65
65
  "@types/express": "^5.0.0",
66
- "@types/ioredis": "^5.0.0",
67
66
  "@types/jest": "^30.0.0",
68
67
  "@types/node": "^22.10.7",
69
68
  "@types/supertest": "^6.0.2",
@@ -0,0 +1,7 @@
1
+ global:
2
+ scrape_interval: 15s
3
+
4
+ scrape_configs:
5
+ - job_name: 'nestjs-service'
6
+ static_configs:
7
+ - targets: ['host.docker.internal:9464']
@@ -4,11 +4,22 @@ import { Observable } from 'rxjs';
4
4
  import { map } from 'rxjs/operators';
5
5
  import { PUBLIC_API_KEY } from '../decorators/public-api.decorator';
6
6
 
7
+ export const RAW_RESPONSE_KEY = 'raw_response';
8
+
7
9
  @Injectable()
8
10
  export class TransformInterceptor<T> implements NestInterceptor<T, unknown> {
9
11
  constructor(private readonly reflector: Reflector) {}
10
12
 
11
13
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
14
+ const isRaw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE_KEY, [
15
+ context.getHandler(),
16
+ context.getClass(),
17
+ ]);
18
+
19
+ if (isRaw) {
20
+ return next.handle();
21
+ }
22
+
12
23
  const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_API_KEY, [
13
24
  context.getHandler(),
14
25
  context.getClass(),
@@ -1,9 +1,11 @@
1
1
  import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
2
2
  import Redis from 'ioredis';
3
3
  import { circuitBreaker, ConsecutiveBreaker, CircuitBreakerPolicy, handleAll } from 'cockatiel';
4
+ {{#IF_OTEL}}
4
5
  import { trace } from '@opentelemetry/api';
5
6
 
6
7
  const tracer = trace.getTracer('redis-service');
8
+ {{/IF_OTEL}}
7
9
 
8
10
  @Injectable()
9
11
  export class RedisService implements OnModuleDestroy {
@@ -46,6 +48,7 @@ export class RedisService implements OnModuleDestroy {
46
48
  }
47
49
 
48
50
  async get(key: string): Promise<string | null> {
51
+ {{#IF_OTEL}}
49
52
  return tracer.startActiveSpan('redis.get', async (span) => {
50
53
  try {
51
54
  span.setAttribute('db.system', 'redis');
@@ -59,9 +62,14 @@ export class RedisService implements OnModuleDestroy {
59
62
  span.end();
60
63
  }
61
64
  });
65
+ {{/IF_OTEL}}
66
+ {{#IF_NOT_OTEL}}
67
+ return this.execute((c) => c.get(key));
68
+ {{/IF_NOT_OTEL}}
62
69
  }
63
70
 
64
71
  async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
72
+ {{#IF_OTEL}}
65
73
  return tracer.startActiveSpan('redis.set', async (span) => {
66
74
  try {
67
75
  span.setAttribute('db.system', 'redis');
@@ -84,9 +92,20 @@ export class RedisService implements OnModuleDestroy {
84
92
  span.end();
85
93
  }
86
94
  });
95
+ {{/IF_OTEL}}
96
+ {{#IF_NOT_OTEL}}
97
+ await this.execute(async (c) => {
98
+ if (ttlSeconds) {
99
+ await c.set(key, value, 'EX', ttlSeconds);
100
+ } else {
101
+ await c.set(key, value);
102
+ }
103
+ });
104
+ {{/IF_NOT_OTEL}}
87
105
  }
88
106
 
89
107
  async del(key: string): Promise<void> {
108
+ {{#IF_OTEL}}
90
109
  return tracer.startActiveSpan('redis.del', async (span) => {
91
110
  try {
92
111
  span.setAttribute('db.system', 'redis');
@@ -100,9 +119,14 @@ export class RedisService implements OnModuleDestroy {
100
119
  span.end();
101
120
  }
102
121
  });
122
+ {{/IF_OTEL}}
123
+ {{#IF_NOT_OTEL}}
124
+ await this.execute((c) => c.del(key).then(() => undefined));
125
+ {{/IF_NOT_OTEL}}
103
126
  }
104
127
 
105
128
  async expire(key: string, ttlSeconds: number): Promise<void> {
129
+ {{#IF_OTEL}}
106
130
  return tracer.startActiveSpan('redis.expire', async (span) => {
107
131
  try {
108
132
  span.setAttribute('db.system', 'redis');
@@ -117,5 +141,9 @@ export class RedisService implements OnModuleDestroy {
117
141
  span.end();
118
142
  }
119
143
  });
144
+ {{/IF_OTEL}}
145
+ {{#IF_NOT_OTEL}}
146
+ await this.execute((c) => c.expire(key, ttlSeconds).then(() => undefined));
147
+ {{/IF_NOT_OTEL}}
120
148
  }
121
149
  }
@@ -10,7 +10,13 @@ export class PrismaHealthIndicator extends HealthIndicator {
10
10
 
11
11
  async isHealthy(key: string): Promise<HealthIndicatorResult> {
12
12
  try {
13
+ {{#IF_POSTGRES}}
13
14
  await this.prisma.$queryRaw`SELECT 1`;
15
+ {{/IF_POSTGRES}}
16
+ {{#IF_MONGO}}
17
+ // Prisma on MongoDB uses $runCommandRaw for ping or simple check
18
+ await this.prisma.$runCommandRaw({ ping: 1 });
19
+ {{/IF_MONGO}}
14
20
  return this.getStatus(key, true);
15
21
  } catch (error) {
16
22
  throw new HealthCheckError('Database check failed', this.getStatus(key, false));
@@ -84,10 +84,19 @@ async function bootstrap() {
84
84
  const port = parseInt(process.env.PORT ?? '3000', 10);
85
85
  const httpServer = await app.listen(port);
86
86
 
87
+ const logger = app.get(Logger);
88
+ logger.log(`🚀 Application is running on: http://localhost:${port}`);
89
+ logger.log(`📖 API Documentation (Scalar): http://localhost:${port}/docs`);
90
+ logger.log(`📄 API Spec (JSON): http://localhost:${port}/docs/json`);
91
+
87
92
  process.on('SIGTERM', () => {
88
93
  loggerService.log('SIGTERM signal received: closing HTTP server');
89
94
  setTimeout(() => {
90
- void otelSdk?.shutdown().catch(() => undefined);
95
+ if (process.env.OTEL_ENABLED === 'true') {
96
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
97
+ const otelSdk = require('./instrumentation').default;
98
+ void otelSdk?.shutdown().catch(() => undefined);
99
+ }
91
100
  httpServer.close(() => {
92
101
  loggerService.log('HTTP server closed');
93
102
  process.exit(0);
@@ -1,6 +1,8 @@
1
1
  import { join } from 'path';
2
2
  import type { Params } from 'nestjs-pino';
3
+ {{#IF_OTEL}}
3
4
  import { trace, context, isSpanContextValid } from '@opentelemetry/api';
5
+ {{/IF_OTEL}}
4
6
 
5
7
  const isDev = process.env.NODE_ENV !== 'production';
6
8
  const LOG_DIR = join(process.cwd(), 'logs');
@@ -11,6 +13,7 @@ export const pinoConfig: Params = {
11
13
  level: isDev ? 'debug' : 'info',
12
14
  // Inject Trace context into every log line
13
15
  mixin() {
16
+ {{#IF_OTEL}}
14
17
  const activeSpan = trace.getSpan(context.active());
15
18
  if (activeSpan) {
16
19
  const spanContext = activeSpan.spanContext();
@@ -22,6 +25,7 @@ export const pinoConfig: Params = {
22
25
  };
23
26
  }
24
27
  }
28
+ {{/IF_OTEL}}
25
29
  return {};
26
30
  },
27
31
  // Standardize on X-Request-Id as the primary req.id