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.
- package/README.md +15 -4
- package/assets/agents/developers/nodejs-backend-dev.md +92 -0
- package/assets/agents/developers/react-frontend-dev.md +32 -19
- package/assets/architecture/nodejs-nestjs.md +949 -0
- package/assets/architecture/react-frontend.md +216 -145
- package/assets/skills/deep-debug/SKILL.md +62 -62
- package/assets/skills/research/SKILL.md +124 -0
- package/assets/skills/review-changes/SKILL.md +312 -0
- package/assets/templates/react-vite/CLAUDE.md +204 -128
- package/package.json +1 -1
|
@@ -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
|