moicle 1.4.0 → 1.6.0

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.
@@ -0,0 +1,949 @@
1
+ # Node.js NestJS Backend Architecture
2
+
3
+ > Production-grade DDD with Hexagonal Architecture for Node.js + NestJS + TypeORM
4
+
5
+ **Prerequisite:** Read `clean-architecture.md` first. This doc shows how DDD maps to NestJS.
6
+
7
+ ## Tech Stack
8
+
9
+ | Component | Technology |
10
+ |-----------|------------|
11
+ | Runtime | Node.js 20+ |
12
+ | Language | TypeScript 5+ |
13
+ | Framework | NestJS 10+ |
14
+ | ORM | TypeORM (@nestjs/typeorm) |
15
+ | Database | PostgreSQL / MySQL |
16
+ | Cache | Redis (ioredis) |
17
+ | Queue | BullMQ |
18
+ | Auth | Passport + JWT / Firebase Admin |
19
+ | Validation | class-validator + class-transformer (or Zod) |
20
+ | Logging | Pino (nestjs-pino) |
21
+ | Config | @nestjs/config |
22
+ | Testing | Jest + Supertest |
23
+ | Storage | AWS S3 / Cloudflare R2 |
24
+ | Real-time | Socket.IO / SSE |
25
+
26
+ ---
27
+
28
+ ## DDD Directory Structure
29
+
30
+ ```
31
+ src/
32
+ ├── domain/{domain}/
33
+ │ ├── entities/ # Aggregates + entities with behavior
34
+ │ │ └── {entity}.ts # Pure class — no decorators, no framework
35
+ │ ├── value-objects/ # Immutable typed values with behavior
36
+ │ │ └── {vo}.ts
37
+ │ ├── ports/ # Hexagonal ports (interfaces) — 1 per file
38
+ │ │ └── {repo-name}.port.ts # Token + interface
39
+ │ ├── events/ # 1 file per domain event
40
+ │ │ └── {event-name}.event.ts # Extends shared BaseEvent
41
+ │ ├── use-cases/ # Business orchestration (pure, no infra)
42
+ │ │ └── {action}.use-case.ts
43
+ │ ├── errors/ # Domain-specific error classes
44
+ │ └── validators/ # (optional) Pure validation rules
45
+
46
+ ├── domain/shared/
47
+ │ ├── base-event.ts # BaseEvent class
48
+ │ └── event-collector.ts # EventCollector mixin for entities
49
+
50
+ ├── application/
51
+ │ ├── {domain}/
52
+ │ │ ├── {domain}.module.ts # NestJS module wiring
53
+ │ │ ├── controllers/
54
+ │ │ │ └── {domain}.controller.ts
55
+ │ │ ├── services/
56
+ │ │ │ └── {domain}.service.ts # Thin wrapper → domain use-cases
57
+ │ │ ├── dtos/
58
+ │ │ │ ├── create-{entity}.dto.ts
59
+ │ │ │ ├── update-{entity}.dto.ts
60
+ │ │ │ └── {entity}-response.dto.ts
61
+ │ │ ├── mappers/
62
+ │ │ │ └── {entity}.mapper.ts # Entity ↔ DTO
63
+ │ │ └── listeners/
64
+ │ │ └── on-{event-name}.listener.ts
65
+ │ └── event-bus/
66
+ │ ├── event-bus.module.ts
67
+ │ └── event-bus.service.ts
68
+
69
+ ├── infrastructure/
70
+ │ ├── persistence/
71
+ │ │ ├── typeorm/
72
+ │ │ │ ├── typeorm.module.ts # TypeOrmModule.forRootAsync
73
+ │ │ │ └── data-source.ts # DataSource for migrations/CLI
74
+ │ │ ├── entities/ # TypeORM @Entity classes (persistence models)
75
+ │ │ │ └── {entity}.orm-entity.ts
76
+ │ │ ├── repositories/
77
+ │ │ │ └── {entity}.repository.ts # Implements domain port
78
+ │ │ └── migrations/
79
+ │ │ └── {timestamp}-{name}.ts
80
+ │ ├── cache/
81
+ │ │ └── redis.service.ts
82
+ │ ├── queue/
83
+ │ │ ├── queue.module.ts
84
+ │ │ └── processors/
85
+ │ │ └── {task}.processor.ts
86
+ │ ├── auth/
87
+ │ │ ├── jwt.strategy.ts
88
+ │ │ └── firebase.service.ts
89
+ │ ├── storage/
90
+ │ │ └── s3.service.ts
91
+ │ ├── http/
92
+ │ │ ├── response.util.ts
93
+ │ │ └── filters/
94
+ │ │ └── all-exceptions.filter.ts
95
+ │ └── logger/
96
+ │ └── logger.module.ts
97
+
98
+ ├── common/
99
+ │ ├── decorators/ # @CurrentUser, @Roles...
100
+ │ ├── guards/ # AuthGuard, RolesGuard
101
+ │ ├── interceptors/ # Logging, transform
102
+ │ ├── pipes/ # Validation pipes
103
+ │ └── middleware/
104
+
105
+ ├── config/
106
+ │ ├── configuration.ts
107
+ │ └── validation.schema.ts
108
+
109
+ ├── app.module.ts
110
+ └── main.ts
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Layer Rules (Import Rules)
116
+
117
+ ```
118
+ domain/value-objects/ → only stdlib / pure TS
119
+ domain/entities/ → only stdlib + domain/shared + domain/value-objects
120
+ domain/ports/ → only stdlib + domain/entities + domain/value-objects
121
+ domain/events/ → only stdlib + domain/shared
122
+ domain/use-cases/ → domain/entities + ports + events + value-objects (NO infra, NO @nestjs/*)
123
+ application/services/ → domain/use-cases (thin wrapper) + @nestjs/common
124
+ application/controllers → application/services + DTOs + @nestjs/common
125
+ application/listeners/ → domain/events + @nestjs/common
126
+ infrastructure/persist. → domain/ports + typeorm
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Hard Rules
132
+
133
+ - `domain/` MUST NOT import `@nestjs/*`, `typeorm`, `ioredis`, `bullmq`, or any framework package
134
+ - `domain/` MUST NOT use decorators (`@Injectable`, `@Entity`, `@Column`...) — pure TypeScript only
135
+ - Domain entities are plain classes; TypeORM `@Entity` classes live in `infrastructure/persistence/entities/` and are separate from domain entities
136
+ - Domain A MUST NOT import Domain B
137
+ - NO circular imports
138
+ - Controllers return DTOs (never domain entities directly); use mappers
139
+ - Ports are interfaces + an `InjectionToken` constant for DI
140
+ - Use cases take primitives/value objects in, return entities out
141
+ - Repository implementations live in `infrastructure/persistence/repositories/` and are bound to ports via DI providers
142
+ - Entities raise events on state changes; listeners handle side-effects
143
+ - All external input validated with `class-validator` / Zod at the controller boundary
144
+
145
+ ---
146
+
147
+ ## Forbidden Imports in Domain
148
+
149
+ ```
150
+ @nestjs/*
151
+ typeorm
152
+ @nestjs/typeorm
153
+ ioredis
154
+ bullmq
155
+ passport*
156
+ firebase-admin
157
+ ```
158
+
159
+ ---
160
+
161
+ ## Domain Layer Examples
162
+
163
+ ### Value Object
164
+
165
+ ```typescript
166
+ // src/domain/wallet/value-objects/money.ts
167
+ export class Money {
168
+ private constructor(
169
+ private readonly _amount: bigint,
170
+ private readonly _currency: string,
171
+ ) {}
172
+
173
+ static of(amount: bigint | number, currency = 'VND'): Money {
174
+ return new Money(typeof amount === 'number' ? BigInt(amount) : amount, currency);
175
+ }
176
+
177
+ static zero(currency = 'VND'): Money {
178
+ return new Money(0n, currency);
179
+ }
180
+
181
+ get amount(): bigint { return this._amount; }
182
+ get currency(): string { return this._currency; }
183
+
184
+ add(other: Money): Money {
185
+ this.assertSameCurrency(other);
186
+ return new Money(this._amount + other._amount, this._currency);
187
+ }
188
+
189
+ subtract(other: Money): Money {
190
+ this.assertSameCurrency(other);
191
+ return new Money(this._amount - other._amount, this._currency);
192
+ }
193
+
194
+ greaterOrEqual(other: Money): boolean {
195
+ this.assertSameCurrency(other);
196
+ return this._amount >= other._amount;
197
+ }
198
+
199
+ private assertSameCurrency(other: Money): void {
200
+ if (this._currency !== other._currency) {
201
+ throw new Error(`Currency mismatch: ${this._currency} vs ${other._currency}`);
202
+ }
203
+ }
204
+ }
205
+
206
+ export type WalletStatus = 'active' | 'frozen' | 'closed';
207
+ ```
208
+
209
+ ### Entity with Behavior
210
+
211
+ ```typescript
212
+ // src/domain/wallet/entities/wallet.ts
213
+ import { randomUUID } from 'crypto';
214
+ import { EventCollector } from '@/domain/shared/event-collector';
215
+ import { Money } from '../value-objects/money';
216
+ import { WalletStatus } from '../value-objects/money';
217
+ import { WalletCreatedEvent } from '../events/wallet-created.event';
218
+ import { WalletWithdrawalCreatedEvent } from '../events/wallet-withdrawal-created.event';
219
+ import { InsufficientBalanceError, WalletInactiveError } from '../errors';
220
+
221
+ export class Wallet extends EventCollector {
222
+ private constructor(
223
+ public readonly id: string,
224
+ public readonly userId: string,
225
+ private _balance: Money,
226
+ private _status: WalletStatus,
227
+ public readonly createdAt: Date,
228
+ private _updatedAt: Date,
229
+ ) {
230
+ super();
231
+ }
232
+
233
+ static create(userId: string): Wallet {
234
+ const now = new Date();
235
+ const wallet = new Wallet(randomUUID(), userId, Money.zero(), 'active', now, now);
236
+ wallet.raise(new WalletCreatedEvent(wallet.id, userId));
237
+ return wallet;
238
+ }
239
+
240
+ static restore(props: {
241
+ id: string; userId: string; balance: Money; status: WalletStatus;
242
+ createdAt: Date; updatedAt: Date;
243
+ }): Wallet {
244
+ return new Wallet(props.id, props.userId, props.balance, props.status, props.createdAt, props.updatedAt);
245
+ }
246
+
247
+ get balance(): Money { return this._balance; }
248
+ get status(): WalletStatus { return this._status; }
249
+ get updatedAt(): Date { return this._updatedAt; }
250
+
251
+ isActive(): boolean {
252
+ return this._status === 'active';
253
+ }
254
+
255
+ withdraw(amount: Money): void {
256
+ if (!this.isActive()) throw new WalletInactiveError(this.id);
257
+ if (!this._balance.greaterOrEqual(amount)) throw new InsufficientBalanceError(this.id);
258
+
259
+ this._balance = this._balance.subtract(amount);
260
+ this._updatedAt = new Date();
261
+ this.raise(new WalletWithdrawalCreatedEvent(this.id, this.userId, amount.amount));
262
+ }
263
+
264
+ deposit(amount: Money): void {
265
+ this._balance = this._balance.add(amount);
266
+ this._updatedAt = new Date();
267
+ }
268
+ }
269
+ ```
270
+
271
+ ### Port (Repository Interface)
272
+
273
+ ```typescript
274
+ // src/domain/wallet/ports/wallet.repository.port.ts
275
+ import { Wallet } from '../entities/wallet';
276
+
277
+ export const WALLET_REPOSITORY = Symbol('WALLET_REPOSITORY');
278
+
279
+ export interface WalletRepository {
280
+ findById(id: string): Promise<Wallet | null>;
281
+ findByUserId(userId: string): Promise<Wallet | null>;
282
+ save(wallet: Wallet): Promise<void>;
283
+ }
284
+ ```
285
+
286
+ ### Domain Event
287
+
288
+ ```typescript
289
+ // src/domain/wallet/events/wallet-withdrawal-created.event.ts
290
+ import { BaseEvent } from '@/domain/shared/base-event';
291
+
292
+ export class WalletWithdrawalCreatedEvent extends BaseEvent {
293
+ static readonly NAME = 'wallet.withdrawal.created';
294
+
295
+ constructor(
296
+ public readonly walletId: string,
297
+ public readonly userId: string,
298
+ public readonly amount: bigint,
299
+ ) {
300
+ super(WalletWithdrawalCreatedEvent.NAME);
301
+ }
302
+ }
303
+ ```
304
+
305
+ ### Use Case
306
+
307
+ ```typescript
308
+ // src/domain/wallet/use-cases/withdraw.use-case.ts
309
+ import { Wallet } from '../entities/wallet';
310
+ import { WalletRepository } from '../ports/wallet.repository.port';
311
+ import { Money } from '../value-objects/money';
312
+ import { WalletNotFoundError } from '../errors';
313
+
314
+ export class WithdrawUseCase {
315
+ constructor(private readonly walletRepo: WalletRepository) {}
316
+
317
+ async execute(walletId: string, amount: Money): Promise<Wallet> {
318
+ const wallet = await this.walletRepo.findById(walletId);
319
+ if (!wallet) throw new WalletNotFoundError(walletId);
320
+
321
+ wallet.withdraw(amount);
322
+ await this.walletRepo.save(wallet);
323
+
324
+ return wallet;
325
+ }
326
+ }
327
+ ```
328
+
329
+ ---
330
+
331
+ ## Application Layer Examples
332
+
333
+ ### Module
334
+
335
+ ```typescript
336
+ // src/application/wallet/wallet.module.ts
337
+ import { Module } from '@nestjs/common';
338
+ import { TypeOrmModule } from '@nestjs/typeorm';
339
+ import { WalletOrmEntity } from '@/infrastructure/persistence/entities/wallet.orm-entity';
340
+ import { WalletController } from './controllers/wallet.controller';
341
+ import { WalletService } from './services/wallet.service';
342
+ import { WALLET_REPOSITORY } from '@/domain/wallet/ports/wallet.repository.port';
343
+ import { TypeOrmWalletRepository } from '@/infrastructure/persistence/repositories/wallet.repository';
344
+ import { WithdrawUseCase } from '@/domain/wallet/use-cases/withdraw.use-case';
345
+ import { OnWalletWithdrawalCreatedListener } from './listeners/on-wallet-withdrawal-created.listener';
346
+
347
+ @Module({
348
+ imports: [TypeOrmModule.forFeature([WalletOrmEntity])],
349
+ controllers: [WalletController],
350
+ providers: [
351
+ WalletService,
352
+ OnWalletWithdrawalCreatedListener,
353
+ TypeOrmWalletRepository,
354
+ {
355
+ provide: WALLET_REPOSITORY,
356
+ useExisting: TypeOrmWalletRepository,
357
+ },
358
+ {
359
+ provide: WithdrawUseCase,
360
+ useFactory: (repo) => new WithdrawUseCase(repo),
361
+ inject: [WALLET_REPOSITORY],
362
+ },
363
+ ],
364
+ })
365
+ export class WalletModule {}
366
+ ```
367
+
368
+ ### Controller
369
+
370
+ ```typescript
371
+ // src/application/wallet/controllers/wallet.controller.ts
372
+ import { Body, Controller, HttpCode, Param, Post, UseGuards } from '@nestjs/common';
373
+ import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
374
+ import { WalletService } from '../services/wallet.service';
375
+ import { WithdrawDto } from '../dtos/withdraw.dto';
376
+ import { WalletResponseDto } from '../dtos/wallet-response.dto';
377
+ import { WalletMapper } from '../mappers/wallet.mapper';
378
+
379
+ @Controller('wallets')
380
+ @UseGuards(JwtAuthGuard)
381
+ export class WalletController {
382
+ constructor(private readonly walletService: WalletService) {}
383
+
384
+ @Post(':id/withdraw')
385
+ @HttpCode(200)
386
+ async withdraw(
387
+ @Param('id') id: string,
388
+ @Body() dto: WithdrawDto,
389
+ ): Promise<WalletResponseDto> {
390
+ const wallet = await this.walletService.withdraw(id, dto.amount, dto.currency);
391
+ return WalletMapper.toResponse(wallet);
392
+ }
393
+ }
394
+ ```
395
+
396
+ ### Service (thin wrapper)
397
+
398
+ ```typescript
399
+ // src/application/wallet/services/wallet.service.ts
400
+ import { Injectable } from '@nestjs/common';
401
+ import { WithdrawUseCase } from '@/domain/wallet/use-cases/withdraw.use-case';
402
+ import { Money } from '@/domain/wallet/value-objects/money';
403
+
404
+ @Injectable()
405
+ export class WalletService {
406
+ constructor(private readonly withdrawUseCase: WithdrawUseCase) {}
407
+
408
+ async withdraw(walletId: string, amount: number, currency: string) {
409
+ return this.withdrawUseCase.execute(walletId, Money.of(amount, currency));
410
+ }
411
+ }
412
+ ```
413
+
414
+ ### DTOs
415
+
416
+ ```typescript
417
+ // src/application/wallet/dtos/withdraw.dto.ts
418
+ import { IsInt, IsPositive, IsString, Length } from 'class-validator';
419
+
420
+ export class WithdrawDto {
421
+ @IsInt()
422
+ @IsPositive()
423
+ amount!: number;
424
+
425
+ @IsString()
426
+ @Length(3, 10)
427
+ currency!: string;
428
+ }
429
+ ```
430
+
431
+ ```typescript
432
+ // src/application/wallet/dtos/wallet-response.dto.ts
433
+ export class WalletResponseDto {
434
+ id!: string;
435
+ userId!: string;
436
+ balance!: number;
437
+ currency!: string;
438
+ status!: string;
439
+ createdAt!: string;
440
+ updatedAt!: string;
441
+ }
442
+ ```
443
+
444
+ ### Mapper
445
+
446
+ ```typescript
447
+ // src/application/wallet/mappers/wallet.mapper.ts
448
+ import { Wallet } from '@/domain/wallet/entities/wallet';
449
+ import { WalletResponseDto } from '../dtos/wallet-response.dto';
450
+
451
+ export class WalletMapper {
452
+ static toResponse(w: Wallet): WalletResponseDto {
453
+ return {
454
+ id: w.id,
455
+ userId: w.userId,
456
+ balance: Number(w.balance.amount),
457
+ currency: w.balance.currency,
458
+ status: w.status,
459
+ createdAt: w.createdAt.toISOString(),
460
+ updatedAt: w.updatedAt.toISOString(),
461
+ };
462
+ }
463
+ }
464
+ ```
465
+
466
+ ### Event Listener
467
+
468
+ ```typescript
469
+ // src/application/wallet/listeners/on-wallet-withdrawal-created.listener.ts
470
+ import { Injectable, Logger } from '@nestjs/common';
471
+ import { OnEvent } from '@nestjs/event-emitter';
472
+ import { WalletWithdrawalCreatedEvent } from '@/domain/wallet/events/wallet-withdrawal-created.event';
473
+
474
+ @Injectable()
475
+ export class OnWalletWithdrawalCreatedListener {
476
+ private readonly logger = new Logger(OnWalletWithdrawalCreatedListener.name);
477
+
478
+ @OnEvent(WalletWithdrawalCreatedEvent.NAME, { async: true })
479
+ async handle(event: WalletWithdrawalCreatedEvent): Promise<void> {
480
+ this.logger.log(`Withdrawal: wallet=${event.walletId} amount=${event.amount}`);
481
+ // send notification, update analytics, enqueue job, etc.
482
+ }
483
+ }
484
+ ```
485
+
486
+ ---
487
+
488
+ ## Infrastructure Layer Examples
489
+
490
+ ### TypeORM Entity (persistence model — separate from domain entity)
491
+
492
+ ```typescript
493
+ // src/infrastructure/persistence/entities/wallet.orm-entity.ts
494
+ import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm';
495
+
496
+ @Entity({ name: 'wallets' })
497
+ @Index(['userId'])
498
+ export class WalletOrmEntity {
499
+ @PrimaryColumn({ type: 'uuid' })
500
+ id!: string;
501
+
502
+ @Column({ name: 'user_id', type: 'uuid' })
503
+ userId!: string;
504
+
505
+ @Column({ type: 'bigint', default: '0', transformer: {
506
+ to: (v: bigint) => v.toString(),
507
+ from: (v: string) => BigInt(v),
508
+ }})
509
+ balance!: bigint;
510
+
511
+ @Column({ type: 'varchar', length: 10, default: 'VND' })
512
+ currency!: string;
513
+
514
+ @Column({ type: 'varchar', length: 20, default: 'active' })
515
+ status!: string;
516
+
517
+ @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
518
+ createdAt!: Date;
519
+
520
+ @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
521
+ updatedAt!: Date;
522
+ }
523
+ ```
524
+
525
+ ### Repository Implementation
526
+
527
+ ```typescript
528
+ // src/infrastructure/persistence/repositories/wallet.repository.ts
529
+ import { Injectable } from '@nestjs/common';
530
+ import { InjectRepository } from '@nestjs/typeorm';
531
+ import { Repository } from 'typeorm';
532
+ import { WalletOrmEntity } from '../entities/wallet.orm-entity';
533
+ import { WalletRepository } from '@/domain/wallet/ports/wallet.repository.port';
534
+ import { Wallet } from '@/domain/wallet/entities/wallet';
535
+ import { Money, WalletStatus } from '@/domain/wallet/value-objects/money';
536
+
537
+ @Injectable()
538
+ export class TypeOrmWalletRepository implements WalletRepository {
539
+ constructor(
540
+ @InjectRepository(WalletOrmEntity)
541
+ private readonly repo: Repository<WalletOrmEntity>,
542
+ ) {}
543
+
544
+ async findById(id: string): Promise<Wallet | null> {
545
+ const row = await this.repo.findOne({ where: { id } });
546
+ return row ? this.toEntity(row) : null;
547
+ }
548
+
549
+ async findByUserId(userId: string): Promise<Wallet | null> {
550
+ const row = await this.repo.findOne({ where: { userId } });
551
+ return row ? this.toEntity(row) : null;
552
+ }
553
+
554
+ async save(wallet: Wallet): Promise<void> {
555
+ await this.repo.save(this.toOrm(wallet));
556
+ }
557
+
558
+ private toEntity(row: WalletOrmEntity): Wallet {
559
+ return Wallet.restore({
560
+ id: row.id,
561
+ userId: row.userId,
562
+ balance: Money.of(row.balance, row.currency),
563
+ status: row.status as WalletStatus,
564
+ createdAt: row.createdAt,
565
+ updatedAt: row.updatedAt,
566
+ });
567
+ }
568
+
569
+ private toOrm(w: Wallet): WalletOrmEntity {
570
+ const orm = new WalletOrmEntity();
571
+ orm.id = w.id;
572
+ orm.userId = w.userId;
573
+ orm.balance = w.balance.amount;
574
+ orm.currency = w.balance.currency;
575
+ orm.status = w.status;
576
+ orm.createdAt = w.createdAt;
577
+ orm.updatedAt = w.updatedAt;
578
+ return orm;
579
+ }
580
+ }
581
+ ```
582
+
583
+ ### TypeORM Module
584
+
585
+ ```typescript
586
+ // src/infrastructure/persistence/typeorm/typeorm.module.ts
587
+ import { Module } from '@nestjs/common';
588
+ import { ConfigModule, ConfigService } from '@nestjs/config';
589
+ import { TypeOrmModule as NestTypeOrmModule } from '@nestjs/typeorm';
590
+
591
+ @Module({
592
+ imports: [
593
+ NestTypeOrmModule.forRootAsync({
594
+ imports: [ConfigModule],
595
+ inject: [ConfigService],
596
+ useFactory: (config: ConfigService) => ({
597
+ type: 'postgres',
598
+ host: config.getOrThrow<string>('DB_HOST'),
599
+ port: config.getOrThrow<number>('DB_PORT'),
600
+ username: config.getOrThrow<string>('DB_USER'),
601
+ password: config.getOrThrow<string>('DB_PASSWORD'),
602
+ database: config.getOrThrow<string>('DB_NAME'),
603
+ entities: [__dirname + '/../entities/*.orm-entity{.ts,.js}'],
604
+ migrations: [__dirname + '/../migrations/*{.ts,.js}'],
605
+ migrationsRun: false,
606
+ synchronize: false,
607
+ logging: config.get<string>('NODE_ENV') !== 'production' ? ['error', 'warn'] : ['error'],
608
+ }),
609
+ }),
610
+ ],
611
+ })
612
+ export class TypeOrmModule {}
613
+ ```
614
+
615
+ ### DataSource (for CLI / migrations)
616
+
617
+ ```typescript
618
+ // src/infrastructure/persistence/typeorm/data-source.ts
619
+ import 'dotenv/config';
620
+ import { DataSource } from 'typeorm';
621
+
622
+ export default new DataSource({
623
+ type: 'postgres',
624
+ host: process.env.DB_HOST!,
625
+ port: Number(process.env.DB_PORT),
626
+ username: process.env.DB_USER!,
627
+ password: process.env.DB_PASSWORD!,
628
+ database: process.env.DB_NAME!,
629
+ entities: [__dirname + '/../entities/*.orm-entity{.ts,.js}'],
630
+ migrations: [__dirname + '/../migrations/*{.ts,.js}'],
631
+ });
632
+ ```
633
+
634
+ ### JWT Auth Guard + Strategy
635
+
636
+ ```typescript
637
+ // src/infrastructure/auth/jwt.strategy.ts
638
+ import { Injectable } from '@nestjs/common';
639
+ import { PassportStrategy } from '@nestjs/passport';
640
+ import { ExtractJwt, Strategy } from 'passport-jwt';
641
+ import { ConfigService } from '@nestjs/config';
642
+
643
+ export interface JwtPayload { sub: string; email: string; }
644
+
645
+ @Injectable()
646
+ export class JwtStrategy extends PassportStrategy(Strategy) {
647
+ constructor(config: ConfigService) {
648
+ super({
649
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
650
+ ignoreExpiration: false,
651
+ secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
652
+ });
653
+ }
654
+
655
+ validate(payload: JwtPayload) {
656
+ return { id: payload.sub, email: payload.email };
657
+ }
658
+ }
659
+ ```
660
+
661
+ ### Background Worker (BullMQ)
662
+
663
+ ```typescript
664
+ // src/infrastructure/queue/processors/notification.processor.ts
665
+ import { Processor, WorkerHost } from '@nestjs/bullmq';
666
+ import { Job } from 'bullmq';
667
+
668
+ export interface NotificationPayload {
669
+ userId: string;
670
+ title: string;
671
+ body: string;
672
+ }
673
+
674
+ @Processor('notifications')
675
+ export class NotificationProcessor extends WorkerHost {
676
+ async process(job: Job<NotificationPayload>): Promise<void> {
677
+ const { userId, title, body } = job.data;
678
+ // dispatch FCM / push notification
679
+ }
680
+ }
681
+ ```
682
+
683
+ ---
684
+
685
+ ## Global Configuration
686
+
687
+ ### main.ts
688
+
689
+ ```typescript
690
+ import { NestFactory } from '@nestjs/core';
691
+ import { ValidationPipe } from '@nestjs/common';
692
+ import { AppModule } from './app.module';
693
+ import { AllExceptionsFilter } from './infrastructure/http/filters/all-exceptions.filter';
694
+
695
+ async function bootstrap() {
696
+ const app = await NestFactory.create(AppModule);
697
+
698
+ app.setGlobalPrefix('v1');
699
+ app.useGlobalPipes(new ValidationPipe({
700
+ whitelist: true,
701
+ forbidNonWhitelisted: true,
702
+ transform: true,
703
+ }));
704
+ app.useGlobalFilters(new AllExceptionsFilter());
705
+ app.enableCors({ origin: process.env.ALLOWED_ORIGINS?.split(',') });
706
+
707
+ await app.listen(process.env.PORT ?? 3000);
708
+ }
709
+
710
+ bootstrap();
711
+ ```
712
+
713
+ ### Global Exception Filter
714
+
715
+ ```typescript
716
+ // src/infrastructure/http/filters/all-exceptions.filter.ts
717
+ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
718
+ import { Response } from 'express';
719
+ import { DomainError } from '@/domain/shared/domain-error';
720
+
721
+ @Catch()
722
+ export class AllExceptionsFilter implements ExceptionFilter {
723
+ private readonly logger = new Logger(AllExceptionsFilter.name);
724
+
725
+ catch(exception: unknown, host: ArgumentsHost): void {
726
+ const ctx = host.switchToHttp();
727
+ const res = ctx.getResponse<Response>();
728
+
729
+ if (exception instanceof HttpException) {
730
+ res.status(exception.getStatus()).json({
731
+ success: false,
732
+ error: exception.getResponse(),
733
+ });
734
+ return;
735
+ }
736
+
737
+ if (exception instanceof DomainError) {
738
+ res.status(HttpStatus.UNPROCESSABLE_ENTITY).json({
739
+ success: false,
740
+ error: { code: exception.code, message: exception.message },
741
+ });
742
+ return;
743
+ }
744
+
745
+ this.logger.error(exception);
746
+ res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
747
+ success: false,
748
+ error: { message: 'Internal server error' },
749
+ });
750
+ }
751
+ }
752
+ ```
753
+
754
+ ---
755
+
756
+ ## Test Patterns
757
+
758
+ ### Entity Tests (pure, no mocks)
759
+
760
+ ```typescript
761
+ // test/domain/wallet/entities/wallet.spec.ts
762
+ import { Wallet } from '@/domain/wallet/entities/wallet';
763
+ import { Money } from '@/domain/wallet/value-objects/money';
764
+ import { InsufficientBalanceError } from '@/domain/wallet/errors';
765
+
766
+ describe('Wallet', () => {
767
+ it('withdraws when balance is sufficient', () => {
768
+ const w = Wallet.create('user-1');
769
+ w.deposit(Money.of(10_000));
770
+
771
+ w.withdraw(Money.of(5_000));
772
+
773
+ expect(w.balance.amount).toBe(5_000n);
774
+ });
775
+
776
+ it('throws when balance is insufficient', () => {
777
+ const w = Wallet.create('user-1');
778
+ w.deposit(Money.of(1_000));
779
+
780
+ expect(() => w.withdraw(Money.of(5_000))).toThrow(InsufficientBalanceError);
781
+ });
782
+
783
+ it('raises event on withdrawal', () => {
784
+ const w = Wallet.create('user-1');
785
+ w.deposit(Money.of(10_000));
786
+ w.clearEvents();
787
+
788
+ w.withdraw(Money.of(5_000));
789
+
790
+ expect(w.events).toHaveLength(1);
791
+ });
792
+ });
793
+ ```
794
+
795
+ ### Use-Case Tests (mock ports)
796
+
797
+ ```typescript
798
+ // test/domain/wallet/use-cases/withdraw.use-case.spec.ts
799
+ import { WithdrawUseCase } from '@/domain/wallet/use-cases/withdraw.use-case';
800
+ import { Wallet } from '@/domain/wallet/entities/wallet';
801
+ import { Money } from '@/domain/wallet/value-objects/money';
802
+ import { WalletRepository } from '@/domain/wallet/ports/wallet.repository.port';
803
+
804
+ describe('WithdrawUseCase', () => {
805
+ it('persists wallet after successful withdrawal', async () => {
806
+ const wallet = Wallet.create('user-1');
807
+ wallet.deposit(Money.of(10_000));
808
+
809
+ const repo: jest.Mocked<WalletRepository> = {
810
+ findById: jest.fn().mockResolvedValue(wallet),
811
+ findByUserId: jest.fn(),
812
+ save: jest.fn().mockResolvedValue(undefined),
813
+ };
814
+
815
+ const uc = new WithdrawUseCase(repo);
816
+ const result = await uc.execute(wallet.id, Money.of(5_000));
817
+
818
+ expect(result.balance.amount).toBe(5_000n);
819
+ expect(repo.save).toHaveBeenCalledWith(wallet);
820
+ });
821
+ });
822
+ ```
823
+
824
+ ### E2E Tests (Supertest)
825
+
826
+ ```typescript
827
+ // test/wallet.e2e-spec.ts
828
+ import { Test } from '@nestjs/testing';
829
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
830
+ import request from 'supertest';
831
+ import { AppModule } from '@/app.module';
832
+
833
+ describe('POST /v1/wallets/:id/withdraw (e2e)', () => {
834
+ let app: INestApplication;
835
+
836
+ beforeAll(async () => {
837
+ const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
838
+ app = moduleRef.createNestApplication();
839
+ app.setGlobalPrefix('v1');
840
+ app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
841
+ await app.init();
842
+ });
843
+
844
+ afterAll(async () => { await app.close(); });
845
+
846
+ it('returns 422 when balance is insufficient', () =>
847
+ request(app.getHttpServer())
848
+ .post('/v1/wallets/abc/withdraw')
849
+ .set('Authorization', `Bearer ${token}`)
850
+ .send({ amount: 999999999, currency: 'VND' })
851
+ .expect(422));
852
+ });
853
+ ```
854
+
855
+ ---
856
+
857
+ ## Naming Conventions
858
+
859
+ | Item | Convention | Example |
860
+ |------|------------|---------|
861
+ | Domain folder | kebab-case | `bank-account/` |
862
+ | File | kebab-case with suffix | `wallet.entity.ts`, `withdraw.use-case.ts` |
863
+ | Class | PascalCase | `Wallet`, `WithdrawUseCase` |
864
+ | Value object class | PascalCase | `Money`, `Email` |
865
+ | Port interface | PascalCase + `Repository`/`Client` | `WalletRepository` |
866
+ | Port token | SCREAMING_SNAKE_CASE as `Symbol` | `WALLET_REPOSITORY` |
867
+ | Use case class | PascalCase + `UseCase` | `WithdrawUseCase` |
868
+ | Service class | PascalCase + `Service` | `WalletService` |
869
+ | Controller class | PascalCase + `Controller` | `WalletController` |
870
+ | DTO | PascalCase + `Dto` | `WithdrawDto`, `WalletResponseDto` |
871
+ | Event class | PascalCase + `Event` | `WalletWithdrawalCreatedEvent` |
872
+ | Event constant (name) | `{domain}.{entity}.{action}` | `wallet.withdrawal.created` |
873
+ | Listener file | `on-{event-name}.listener.ts` | `on-wallet-withdrawal-created.listener.ts` |
874
+ | Constants | UPPER_SNAKE_CASE | `WALLET_REPOSITORY` |
875
+
876
+ ---
877
+
878
+ ## Check Scripts
879
+
880
+ ```bash
881
+ DOMAIN={domain}
882
+
883
+ echo "=== Typecheck ==="
884
+ npx tsc --noEmit && echo "PASS" || echo "FAIL"
885
+
886
+ echo "=== Lint ==="
887
+ npx eslint "src/**/*.ts" && echo "PASS" || echo "FAIL"
888
+
889
+ echo "=== Domain Purity ==="
890
+ grep -rEn '"@nestjs/|"typeorm"|"@nestjs/typeorm|"ioredis|"bullmq|"firebase-admin|"passport' src/domain/$DOMAIN/ \
891
+ && echo "FAIL: infra in domain" || echo "PASS"
892
+
893
+ echo "=== No Cross-Domain Imports ==="
894
+ for d in $(ls src/domain | grep -v shared | grep -v $DOMAIN); do
895
+ grep -rEn "from '@/domain/$d" src/domain/$DOMAIN/ && echo "FAIL: imports domain/$d"
896
+ done
897
+
898
+ echo "=== Tests ==="
899
+ npx jest src/domain/$DOMAIN && echo "PASS" || echo "FAIL"
900
+ ```
901
+
902
+ ---
903
+
904
+ ## Package Scripts
905
+
906
+ ```json
907
+ {
908
+ "scripts": {
909
+ "dev": "nest start --watch",
910
+ "build": "nest build",
911
+ "start": "node dist/main.js",
912
+ "test": "jest",
913
+ "test:e2e": "jest --config ./test/jest-e2e.json",
914
+ "lint": "eslint \"{src,test}/**/*.ts\" --fix",
915
+ "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d src/infrastructure/persistence/typeorm/data-source.ts",
916
+ "migration:gen": "pnpm typeorm migration:generate src/infrastructure/persistence/migrations/$NAME",
917
+ "migration:create": "pnpm typeorm migration:create src/infrastructure/persistence/migrations/$NAME",
918
+ "migration:run": "pnpm typeorm migration:run",
919
+ "migration:revert": "pnpm typeorm migration:revert"
920
+ }
921
+ }
922
+ ```
923
+
924
+ ---
925
+
926
+ ## When to Use What
927
+
928
+ | Scenario | Solution |
929
+ |----------|----------|
930
+ | New business capability | New domain with entities, ports, use-cases + NestJS module |
931
+ | Simple CRUD | Entity + repository port + single use-case |
932
+ | Complex validation | Pure validator classes in `domain/validators` |
933
+ | Background jobs | BullMQ processor in `infrastructure/queue/processors` |
934
+ | Real-time events | Socket.IO gateway + Redis adapter |
935
+ | Cross-domain communication | Domain events via `@nestjs/event-emitter` or BullMQ |
936
+ | File upload | S3/R2 adapter in `infrastructure/storage` |
937
+ | Push notifications | FCM service enqueued via BullMQ |
938
+ | Scheduled tasks | `@nestjs/schedule` + use-case invocation |
939
+
940
+ ---
941
+
942
+ ## Rules of Thumb
943
+
944
+ - Controllers are thin: parse DTO → call service → map to response DTO
945
+ - Services are thin: wrap one use-case call, convert primitives to value objects
946
+ - Business rules live ONLY in entities, value objects, and use-cases
947
+ - Repositories hide Prisma completely — use-cases never see `PrismaClient`
948
+ - Validate at boundaries (controller DTOs); trust types inside the app
949
+ - Prefer domain events over direct cross-module calls