@tndhuy/create-app 1.1.4 → 1.2.2
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 +82 -4
- package/package.json +1 -1
- package/templates/mongo/README.md +128 -0
- package/templates/mongo/docker-compose.yml +76 -4
- package/templates/mongo/prometheus.yml +7 -0
- package/templates/mongo/src/common/interceptors/transform.interceptor.ts +11 -0
- package/templates/mongo/src/main.ts +5 -1
- package/templates/postgres/README.md +76 -0
- package/templates/postgres/docker-compose.yml +107 -0
- package/templates/postgres/package.json +1 -5
- package/templates/postgres/prometheus.yml +7 -0
- package/templates/postgres/src/common/interceptors/transform.interceptor.ts +11 -0
- package/templates/postgres/src/main.ts +5 -1
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,48 @@ 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
|
+
if (content2.includes(startTag)) {
|
|
185
|
+
if (flag) {
|
|
186
|
+
return content2.replaceAll(startTag, "").replaceAll(endTag, "");
|
|
187
|
+
} else {
|
|
188
|
+
const escapedStart = startTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
|
|
189
|
+
const escapedEnd = endTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
|
|
190
|
+
const regex = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, "g");
|
|
191
|
+
return content2.replace(regex, "");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return content2;
|
|
195
|
+
};
|
|
196
|
+
content = checkBlock(content, isPrisma, "PRISMA");
|
|
197
|
+
content = checkBlock(content, isMongoose, "MONGOOSE");
|
|
198
|
+
content = checkBlock(content, hasRedis, "REDIS");
|
|
199
|
+
content = checkBlock(content, hasOtel, "OTEL");
|
|
200
|
+
content = checkBlock(content, hasKafka, "KAFKA");
|
|
201
|
+
content = checkBlock(content, isPostgres, "POSTGRES");
|
|
202
|
+
content = checkBlock(content, isMongo, "MONGO");
|
|
203
|
+
modified = content !== await (0, import_promises2.readFile)(fullPath, "utf-8");
|
|
173
204
|
for (const [from, to] of replacements) {
|
|
174
205
|
if (content.includes(from)) {
|
|
175
206
|
content = content.replaceAll(from, to);
|
|
@@ -216,8 +247,21 @@ async function patchPackageJson(destDir, options) {
|
|
|
216
247
|
const raw = await (0, import_promises2.readFile)(pkgPath, "utf-8");
|
|
217
248
|
const pkg = JSON.parse(raw);
|
|
218
249
|
pkg.name = options.serviceName;
|
|
250
|
+
delete pkg.workspaces;
|
|
219
251
|
const PRISMA_LATEST = "^7.5.0";
|
|
220
252
|
const PRISMA_MONGO_COMPAT = "^6.0.0";
|
|
253
|
+
if (options.orm === "prisma") {
|
|
254
|
+
if (!pkg.scripts) pkg.scripts = {};
|
|
255
|
+
pkg.scripts["db:generate"] = "prisma generate";
|
|
256
|
+
pkg.scripts["db:push"] = "prisma db push";
|
|
257
|
+
pkg.scripts["db:pull"] = "prisma db pull";
|
|
258
|
+
pkg.scripts["db:studio"] = "prisma studio";
|
|
259
|
+
pkg.scripts["db:format"] = "prisma format";
|
|
260
|
+
if (options.db === "postgres") {
|
|
261
|
+
pkg.scripts["db:migrate:dev"] = "prisma migrate dev";
|
|
262
|
+
pkg.scripts["db:migrate:deploy"] = "prisma migrate deploy";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
221
265
|
if (options.db === "mongo") {
|
|
222
266
|
if (options.orm === "prisma") {
|
|
223
267
|
if (pkg.dependencies) {
|
|
@@ -393,8 +437,31 @@ async function removeOtel(destDir) {
|
|
|
393
437
|
// Remove: import otelSdk from './instrumentation';
|
|
394
438
|
/^import\s+\w+\s+from\s+['"][^'"]*instrumentation['"];\n?/m,
|
|
395
439
|
// Remove: otelSdk?.start(); line
|
|
396
|
-
/^\s*\w+Sdk\?\.start\(\);\n?/m
|
|
440
|
+
/^\s*\w+Sdk\?\.start\(\);\n?/m,
|
|
441
|
+
// Remove shutdown logic: void otelSdk?.shutdown()...
|
|
442
|
+
/^\s*void\s+otelSdk\?\.shutdown\(\)\.catch\(\(\)\s*=>\s*undefined\);\n?/m
|
|
397
443
|
]);
|
|
444
|
+
const pinoConfigPath = (0, import_path2.join)(destDir, "src", "shared", "logger", "pino.config.ts");
|
|
445
|
+
if (await pathExists(pinoConfigPath)) {
|
|
446
|
+
await removeMatchingLines(pinoConfigPath, [
|
|
447
|
+
// Remove OTel imports
|
|
448
|
+
/^import\s*\{[^}]*trace[^}]*\}\s*from\s*['"]@opentelemetry\/api['"];\n?/m,
|
|
449
|
+
// Remove mixin block (more robust regex for multiline)
|
|
450
|
+
/^\s*mixin\(\)\s*\{[\s\S]*?\n\s*\},\n/m
|
|
451
|
+
]);
|
|
452
|
+
}
|
|
453
|
+
const redisServicePath = (0, import_path2.join)(destDir, "src", "infrastructure", "cache", "redis.service.ts");
|
|
454
|
+
if (await pathExists(redisServicePath)) {
|
|
455
|
+
await removeMatchingLines(redisServicePath, [
|
|
456
|
+
/^import\s*\{[^}]*trace[^}]*\}\s*from\s*['"]@opentelemetry\/api['"];\n?/m,
|
|
457
|
+
// Remove tracing spans from methods
|
|
458
|
+
/^\s*const\s+span\s*=\s*trace\.getTracer[\s\S]*?span\.end\(\);\n/gm,
|
|
459
|
+
// Fallback: remove any remaining span.end() or span related lines
|
|
460
|
+
/^\s*span\.end\(\);\n/gm,
|
|
461
|
+
/^\s*const\s+span\s*=\s*trace\.getTracer.*\n/gm
|
|
462
|
+
]);
|
|
463
|
+
}
|
|
464
|
+
await safeDeleteFile(destDir, (0, import_path2.join)(destDir, "prometheus.yml"));
|
|
398
465
|
}
|
|
399
466
|
async function addKafka(destDir, serviceName) {
|
|
400
467
|
await generateKafkaModule(destDir, serviceName);
|
|
@@ -464,7 +531,7 @@ async function scaffold(options) {
|
|
|
464
531
|
}
|
|
465
532
|
await (0, import_promises2.cp)(templateDir, destDir, { recursive: true });
|
|
466
533
|
const replacements = buildReplacements(serviceName);
|
|
467
|
-
await replaceFileContents(destDir, replacements);
|
|
534
|
+
await replaceFileContents(destDir, replacements, options);
|
|
468
535
|
await renamePathsWithPlaceholders(destDir, replacements);
|
|
469
536
|
await patchPackageJson(destDir, options);
|
|
470
537
|
if (options.db === "mongo" && options.orm === "prisma") {
|
|
@@ -535,6 +602,7 @@ async function main() {
|
|
|
535
602
|
};
|
|
536
603
|
if (positionalName && !validateServiceName(positionalName)) {
|
|
537
604
|
config.serviceName = positionalName;
|
|
605
|
+
(0, import_prompts.note)(`Using service name: ${config.serviceName} (from arguments)`, "Info");
|
|
538
606
|
}
|
|
539
607
|
let step = config.serviceName ? 1 : 0;
|
|
540
608
|
const totalSteps = 4;
|
|
@@ -711,6 +779,16 @@ Modules : ${selectedModules}`,
|
|
|
711
779
|
try {
|
|
712
780
|
await (0, import_execa.execa)(pkgManager, ["install"], { cwd: destDir, stdio: "inherit" });
|
|
713
781
|
is.stop("Dependencies installed successfully.");
|
|
782
|
+
if (config.orm === "prisma") {
|
|
783
|
+
const ps = (0, import_prompts.spinner)();
|
|
784
|
+
ps.start("Generating Prisma Client...");
|
|
785
|
+
try {
|
|
786
|
+
await (0, import_execa.execa)(pkgManager, ["run", "db:generate"], { cwd: destDir });
|
|
787
|
+
ps.stop("Prisma Client generated successfully.");
|
|
788
|
+
} catch (err) {
|
|
789
|
+
ps.stop("Prisma Client generation failed. You may need to run it manually.");
|
|
790
|
+
}
|
|
791
|
+
}
|
|
714
792
|
} catch (err) {
|
|
715
793
|
is.stop("Dependency installation failed.");
|
|
716
794
|
}
|
package/package.json
CHANGED
|
@@ -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:
|
|
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:
|
|
37
|
+
retries: 5
|
|
38
|
+
{{/IF_MONGO}}
|
|
17
39
|
|
|
40
|
+
{{#IF_REDIS}}
|
|
18
41
|
redis:
|
|
19
42
|
image: redis:latest
|
|
20
|
-
container_name:
|
|
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:
|
|
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}}
|
|
@@ -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(),
|
|
@@ -87,7 +87,11 @@ async function bootstrap() {
|
|
|
87
87
|
process.on('SIGTERM', () => {
|
|
88
88
|
loggerService.log('SIGTERM signal received: closing HTTP server');
|
|
89
89
|
setTimeout(() => {
|
|
90
|
-
|
|
90
|
+
if (process.env.OTEL_ENABLED === 'true') {
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
92
|
+
const otelSdk = require('./instrumentation').default;
|
|
93
|
+
void otelSdk?.shutdown().catch(() => undefined);
|
|
94
|
+
}
|
|
91
95
|
httpServer.close(() => {
|
|
92
96
|
loggerService.log('HTTP server closed');
|
|
93
97
|
process.exit(0);
|
|
@@ -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}}
|
|
@@ -4,10 +4,7 @@
|
|
|
4
4
|
"description": "",
|
|
5
5
|
"author": "",
|
|
6
6
|
"private": true,
|
|
7
|
-
"license": "
|
|
8
|
-
"workspaces": [
|
|
9
|
-
"packages/*"
|
|
10
|
-
],
|
|
7
|
+
"license": "MIT",
|
|
11
8
|
"scripts": {
|
|
12
9
|
"build": "nest build",
|
|
13
10
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
@@ -63,7 +60,6 @@
|
|
|
63
60
|
"@nestjs/schematics": "^11.0.0",
|
|
64
61
|
"@nestjs/testing": "^11.0.1",
|
|
65
62
|
"@types/express": "^5.0.0",
|
|
66
|
-
"@types/ioredis": "^5.0.0",
|
|
67
63
|
"@types/jest": "^30.0.0",
|
|
68
64
|
"@types/node": "^22.10.7",
|
|
69
65
|
"@types/supertest": "^6.0.2",
|
|
@@ -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(),
|
|
@@ -87,7 +87,11 @@ async function bootstrap() {
|
|
|
87
87
|
process.on('SIGTERM', () => {
|
|
88
88
|
loggerService.log('SIGTERM signal received: closing HTTP server');
|
|
89
89
|
setTimeout(() => {
|
|
90
|
-
|
|
90
|
+
if (process.env.OTEL_ENABLED === 'true') {
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
92
|
+
const otelSdk = require('./instrumentation').default;
|
|
93
|
+
void otelSdk?.shutdown().catch(() => undefined);
|
|
94
|
+
}
|
|
91
95
|
httpServer.close(() => {
|
|
92
96
|
loggerService.log('HTTP server closed');
|
|
93
97
|
process.exit(0);
|