@choblue/claude-code-toolkit 1.1.2 → 1.1.6

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,567 @@
1
+ ---
2
+ name: ddd
3
+ description: DDD 전술적 패턴 가이드. Entity, Value Object, Aggregate, Repository, Domain Service, Domain Event 등 도메인 모델링 시 참조한다.
4
+ ---
5
+
6
+ # DDD Skill - DDD 전술적 패턴 규칙
7
+
8
+ 도메인 주도 설계(DDD)의 전술적 패턴과 규칙을 정의한다.
9
+ NestJS 레이어 규칙은 `../Coding/backend.md`, 공통 코딩 원칙은 `../Coding/SKILL.md`를 함께 참고한다.
10
+
11
+ ---
12
+
13
+ ## 1. Entity
14
+
15
+ ### 정의
16
+ - 식별자(id)로 구분되는 도메인 객체다
17
+ - 동등성 비교는 id로 한다 (`equals` 메서드)
18
+ - 비즈니스 로직을 Entity 내부에 캡슐화한다 (Rich Domain Model)
19
+
20
+ ```typescript
21
+ // Bad - Anemic Entity (getter/setter만 있는 빈 껍데기)
22
+ class Order {
23
+ id: string;
24
+ status: OrderStatus;
25
+ items: OrderItem[];
26
+ totalAmount: number;
27
+
28
+ getId(): string { return this.id; }
29
+ getStatus(): OrderStatus { return this.status; }
30
+ setStatus(status: OrderStatus): void { this.status = status; }
31
+ setTotalAmount(amount: number): void { this.totalAmount = amount; }
32
+ }
33
+
34
+ // Service에 비즈니스 로직이 흩어져 있음
35
+ class OrderService {
36
+ cancel(order: Order): void {
37
+ if (order.getStatus() !== OrderStatus.PENDING) {
38
+ throw new Error('Cannot cancel');
39
+ }
40
+ order.setStatus(OrderStatus.CANCELLED);
41
+ }
42
+
43
+ calculateTotal(order: Order): void {
44
+ let total = 0;
45
+ for (const item of order.items) {
46
+ total += item.price * item.quantity;
47
+ }
48
+ order.setTotalAmount(total);
49
+ }
50
+ }
51
+ ```
52
+
53
+ ```typescript
54
+ // Good - Rich Entity (상태 변경 메서드를 Entity가 직접 제공, 불변식 보호)
55
+ class Order {
56
+ private readonly _id: string;
57
+ private _status: OrderStatus;
58
+ private readonly _items: OrderItem[];
59
+
60
+ constructor(id: string, items: OrderItem[]) {
61
+ if (items.length === 0) {
62
+ throw new Error('Order must have at least one item');
63
+ }
64
+ this._id = id;
65
+ this._status = OrderStatus.PENDING;
66
+ this._items = items;
67
+ }
68
+
69
+ get id(): string { return this._id; }
70
+ get status(): OrderStatus { return this._status; }
71
+ get items(): ReadonlyArray<OrderItem> { return [...this._items]; }
72
+
73
+ get totalAmount(): number {
74
+ return this._items.reduce(
75
+ (sum, item) => sum + item.price * item.quantity,
76
+ 0,
77
+ );
78
+ }
79
+
80
+ cancel(): void {
81
+ if (this._status !== OrderStatus.PENDING) {
82
+ throw new Error('Only pending orders can be cancelled');
83
+ }
84
+ this._status = OrderStatus.CANCELLED;
85
+ }
86
+
87
+ confirm(): void {
88
+ if (this._status !== OrderStatus.PENDING) {
89
+ throw new Error('Only pending orders can be confirmed');
90
+ }
91
+ this._status = OrderStatus.CONFIRMED;
92
+ }
93
+
94
+ equals(other: Order): boolean {
95
+ return this._id === other._id;
96
+ }
97
+ }
98
+ ```
99
+
100
+ ### 핵심 원칙
101
+ - Entity는 자신의 상태를 스스로 보호한다 (불변식 보호)
102
+ - 외부에서 직접 상태를 변경하지 못하게 한다 (setter 노출 금지)
103
+ - 도메인 행위는 Entity 메서드로 표현한다
104
+
105
+ ---
106
+
107
+ ## 2. Value Object
108
+
109
+ ### 정의
110
+ - 불변(immutable) 객체다
111
+ - 속성 기반 동등성으로 비교한다 (`equals`)
112
+ - 생성 시 자기 검증을 수행한다 (잘못된 상태로 생성 불가)
113
+ - 팩토리 메서드(`static create`)로 생성한다
114
+
115
+ ```typescript
116
+ // Bad - 원시값으로 도메인 개념 표현
117
+ class Order {
118
+ constructor(
119
+ public readonly id: string,
120
+ public readonly customerEmail: string, // 단순 string
121
+ public readonly totalAmount: number, // 단순 number - 음수 가능
122
+ public readonly currency: string, // "KRW"? "krw"? 검증 없음
123
+ ) {}
124
+ }
125
+
126
+ // 유효성 검증이 여기저기 흩어짐
127
+ function createOrder(email: string, amount: number) {
128
+ if (!email.includes('@')) throw new Error('Invalid email');
129
+ if (amount < 0) throw new Error('Invalid amount');
130
+ // ...
131
+ }
132
+ ```
133
+
134
+ ```typescript
135
+ // Good - Value Object로 감싸기
136
+ class Email {
137
+ private readonly _value: string;
138
+
139
+ private constructor(value: string) {
140
+ this._value = value;
141
+ }
142
+
143
+ static create(value: string): Email {
144
+ if (!value || !value.includes('@')) {
145
+ throw new Error(`Invalid email: ${value}`);
146
+ }
147
+ return new Email(value.trim().toLowerCase());
148
+ }
149
+
150
+ get value(): string { return this._value; }
151
+
152
+ equals(other: Email): boolean {
153
+ return this._value === other._value;
154
+ }
155
+ }
156
+ ```
157
+
158
+ ### 핵심 원칙
159
+ - 원시값 대신 Value Object를 사용하여 도메인 개념을 명시적으로 표현한다
160
+ - 생성자를 `private`으로 두고 `static create` 팩토리 메서드로 유효성 검사를 강제한다
161
+ - 상태 변경이 필요하면 새로운 인스턴스를 반환한다 (불변 유지)
162
+ - 도메인 연산(add, multiply 등)을 Value Object 메서드로 제공한다
163
+
164
+ ---
165
+
166
+ ## 3. Aggregate
167
+
168
+ ### 정의
169
+ - Aggregate Root를 통해서만 내부 Entity에 접근한다
170
+ - 일관성 경계(consistency boundary)를 정의한다
171
+ - 트랜잭션 범위 = Aggregate 경계다
172
+ - 불변식(invariant)을 보호한다
173
+ - Aggregate 간 참조는 ID로만 한다 (직접 참조 금지)
174
+
175
+ ```typescript
176
+ // Bad - Aggregate 경계 없이 Entity끼리 직접 참조
177
+ class Order {
178
+ id: string;
179
+ items: OrderItem[];
180
+ customer: Customer; // 다른 Aggregate를 직접 참조
181
+ }
182
+
183
+ class OrderItem {
184
+ product: Product; // 다른 Aggregate를 직접 참조
185
+ }
186
+
187
+ // 외부에서 내부 Entity를 직접 조작
188
+ const order = await orderRepository.findById(orderId);
189
+ order.items[0].quantity = 999; // 불변식 검증 없이 직접 변경
190
+ order.customer.name = 'hacked'; // 다른 Aggregate의 상태를 직접 변경
191
+ ```
192
+
193
+ ```typescript
194
+ // Good - Aggregate Root가 내부 Entity를 관리
195
+ class Order {
196
+ private readonly _id: string;
197
+ private readonly _customerId: string; // ID로만 참조
198
+ private readonly _items: OrderItem[];
199
+ private _status: OrderStatus;
200
+
201
+ constructor(id: string, customerId: string, items: OrderItem[]) {
202
+ if (items.length === 0) {
203
+ throw new Error('Order must have at least one item');
204
+ }
205
+ this._id = id;
206
+ this._customerId = customerId;
207
+ this._items = items;
208
+ this._status = OrderStatus.PENDING;
209
+ }
210
+
211
+ get id(): string { return this._id; }
212
+ get customerId(): string { return this._customerId; }
213
+ get items(): ReadonlyArray<OrderItem> { return [...this._items]; }
214
+ get status(): OrderStatus { return this._status; }
215
+
216
+ // Aggregate Root를 통해서만 내부 Entity 조작
217
+ addItem(productId: string, price: Money, quantity: number): void {
218
+ if (this._status !== OrderStatus.PENDING) {
219
+ throw new Error('Cannot modify confirmed order');
220
+ }
221
+ const existing = this._items.find(i => i.productId === productId);
222
+ if (existing) {
223
+ existing.increaseQuantity(quantity);
224
+ } else {
225
+ this._items.push(new OrderItem(productId, price, quantity));
226
+ }
227
+ }
228
+
229
+ removeItem(productId: string): void {
230
+ if (this._status !== OrderStatus.PENDING) {
231
+ throw new Error('Cannot modify confirmed order');
232
+ }
233
+ const index = this._items.findIndex(i => i.productId === productId);
234
+ if (index === -1) {
235
+ throw new Error(`Item not found: ${productId}`);
236
+ }
237
+ this._items.splice(index, 1);
238
+ if (this._items.length === 0) {
239
+ throw new Error('Order must have at least one item');
240
+ }
241
+ }
242
+
243
+ get totalAmount(): Money {
244
+ return this._items.reduce(
245
+ (sum, item) => sum.add(item.subtotal),
246
+ Money.create(0, 'KRW'),
247
+ );
248
+ }
249
+ }
250
+ ```
251
+
252
+ ### 핵심 원칙
253
+ - Aggregate Root만 외부에 공개한다 (내부 Entity는 Root를 통해서만 접근)
254
+ - 불변식은 Aggregate 내부에서 항상 보장한다
255
+ - Aggregate 간에는 객체 참조 대신 ID 참조를 사용한다
256
+ - 하나의 트랜잭션에서 하나의 Aggregate만 변경한다
257
+ - Aggregate를 작게 설계한다 (필요한 Entity만 포함)
258
+
259
+ ---
260
+
261
+ ## 4. Repository
262
+
263
+ ### 정의
264
+ - 도메인 관점의 컬렉션 인터페이스다 (도메인 레이어에 인터페이스 정의)
265
+ - 인프라 레이어에서 구현한다 (TypeORM, Prisma 등)
266
+ - Aggregate 단위로 저장/조회한다
267
+
268
+ ```typescript
269
+ // Bad - Service에서 직접 ORM 호출
270
+ class OrderService {
271
+ constructor(
272
+ @InjectRepository(OrderEntity)
273
+ private readonly orderRepo: Repository<OrderEntity>,
274
+ ) {}
275
+
276
+ async findOrder(id: string): Promise<Order> {
277
+ // 도메인 레이어가 인프라(TypeORM)에 직접 의존
278
+ const entity = await this.orderRepo.findOne({
279
+ where: { id },
280
+ relations: ['items'],
281
+ });
282
+ return entity;
283
+ }
284
+ }
285
+ ```
286
+
287
+ ```typescript
288
+ // Good - Repository 인터페이스를 도메인 레이어에 정의
289
+
290
+ // domain/order/order.repository.ts (도메인 레이어 - 인터페이스만)
291
+ interface OrderRepository {
292
+ findById(id: string): Promise<Order | null>;
293
+ findByCustomerId(customerId: string): Promise<Order[]>;
294
+ save(order: Order): Promise<void>;
295
+ delete(id: string): Promise<void>;
296
+ }
297
+ ```
298
+
299
+ ### 핵심 원칙
300
+ - Repository 인터페이스는 도메인 레이어에 위치한다
301
+ - 구현체는 인프라 레이어에 위치한다 (의존성 역전)
302
+ - Repository는 Aggregate Root 단위로 정의한다 (OrderItem용 Repository는 만들지 않는다)
303
+ - Mapper를 사용하여 도메인 모델과 영속성 모델(ORM Entity)을 분리한다
304
+
305
+ ---
306
+
307
+ ## 5. Domain Service
308
+
309
+ ### 정의
310
+ - Entity나 Value Object에 속하지 않는 도메인 로직을 담당한다
311
+ - 여러 Aggregate를 조율하는 로직을 수행한다
312
+ - 상태를 가지지 않는다 (stateless)
313
+
314
+ ```typescript
315
+ // Bad - 특정 Entity에 속하는 로직을 Domain Service에 두기
316
+ class OrderDomainService {
317
+ cancelOrder(order: Order): void {
318
+ // 이 로직은 Order Entity에 속해야 한다
319
+ if (order.status !== OrderStatus.PENDING) {
320
+ throw new Error('Cannot cancel');
321
+ }
322
+ order.status = OrderStatus.CANCELLED;
323
+ }
324
+ }
325
+ ```
326
+
327
+ ```typescript
328
+ // Good - 여러 Aggregate 간 계산/조율 로직을 Domain Service에 두기
329
+ class PricingService {
330
+ calculateDiscount(
331
+ order: Order,
332
+ customerTier: CustomerTier,
333
+ promotions: Promotion[],
334
+ ): Money {
335
+ let discount = Money.create(0, 'KRW');
336
+ if (customerTier === CustomerTier.VIP) {
337
+ discount = discount.add(order.totalAmount.multiply(0.1));
338
+ }
339
+ for (const promo of promotions) {
340
+ if (promo.isApplicableTo(order)) {
341
+ discount = discount.add(promo.calculateDiscount(order.totalAmount));
342
+ }
343
+ }
344
+ return discount;
345
+ }
346
+ }
347
+ ```
348
+
349
+ ### 핵심 원칙
350
+ - 단일 Entity에 속하는 로직은 Domain Service가 아닌 해당 Entity에 둔다
351
+ - Domain Service는 여러 Aggregate 간의 조율이 필요할 때만 사용한다
352
+ - 상태를 가지지 않는다 (모든 데이터는 매개변수로 받는다)
353
+ - Domain Service와 Application Service를 혼동하지 않는다
354
+ - Domain Service: 순수 도메인 로직 (할인 계산 등)
355
+ - Application Service: 유스케이스 흐름 조율 (트랜잭션, 이벤트 발행)
356
+
357
+ ---
358
+
359
+ ## 6. Domain Event
360
+
361
+ ### 정의
362
+ - 도메인에서 발생한 중요한 변경을 알리는 메시지다
363
+ - 이벤트 이름은 과거형으로 작성한다 (OrderPlaced, PaymentCompleted)
364
+ - Aggregate에서 이벤트를 수집하고, Application 레이어에서 발행한다
365
+
366
+ ```typescript
367
+ // Bad - 직접 의존성 호출
368
+ class OrderService {
369
+ constructor(
370
+ private readonly orderRepo: OrderRepository,
371
+ private readonly emailService: EmailService, // 외부 서비스 직접 의존
372
+ private readonly inventoryService: InventoryService, // 외부 서비스 직접 의존
373
+ private readonly pointService: PointService, // 외부 서비스 직접 의존
374
+ ) {}
375
+
376
+ async placeOrder(command: PlaceOrderCommand): Promise<void> {
377
+ const order = Order.create(command);
378
+ await this.orderRepo.save(order);
379
+ // 직접 호출 - 결합도 높음, 하나가 실패하면 전체 실패
380
+ await this.emailService.sendOrderConfirmation(order);
381
+ await this.inventoryService.decreaseStock(order.items);
382
+ await this.pointService.accumulatePoints(order.customerId, order.totalAmount);
383
+ }
384
+ }
385
+ ```
386
+
387
+ ```typescript
388
+ // Good - Domain Event 패턴
389
+
390
+ // 도메인 이벤트 정의
391
+ class OrderPlacedEvent {
392
+ readonly occurredAt: Date;
393
+
394
+ constructor(
395
+ readonly orderId: string,
396
+ readonly customerId: string,
397
+ readonly items: ReadonlyArray<{ productId: string; quantity: number }>,
398
+ readonly totalAmount: number,
399
+ ) {
400
+ this.occurredAt = new Date();
401
+ }
402
+ }
403
+
404
+ // Application Service에서 이벤트 발행
405
+ class PlaceOrderUseCase {
406
+ constructor(
407
+ private readonly orderRepo: OrderRepository,
408
+ private readonly eventPublisher: DomainEventPublisher,
409
+ ) {}
410
+
411
+ async execute(command: PlaceOrderCommand): Promise<void> {
412
+ const order = Order.place(command.id, command.customerId, command.items);
413
+ await this.orderRepo.save(order);
414
+ // Aggregate에서 수집한 이벤트를 발행
415
+ await this.eventPublisher.publishAll(order.domainEvents);
416
+ order.clearDomainEvents();
417
+ }
418
+ }
419
+ ```
420
+
421
+ ### 핵심 원칙
422
+ - 이벤트 이름은 과거형으로 작성한다 (OrderPlaced, not PlaceOrder)
423
+ - 이벤트는 불변 객체다 (생성 후 변경하지 않는다)
424
+ - Aggregate 내부에서 이벤트를 수집하고, Application 레이어에서 발행한다
425
+ - 이벤트 핸들러 간에는 순서 의존성을 두지 않는다
426
+ - 이벤트를 통해 Aggregate 간 결합도를 낮춘다
427
+
428
+ ---
429
+
430
+ ## 7. 레이어 구조
431
+
432
+ ### 의존성 방향
433
+
434
+ ```
435
+ Presentation → Application → Domain ← Infrastructure
436
+ ```
437
+
438
+ - 화살표는 의존 방향이다. Domain 레이어는 아무것에도 의존하지 않는다.
439
+ - Infrastructure는 Domain의 인터페이스를 구현한다 (의존성 역전).
440
+
441
+ ### 각 레이어의 역할
442
+
443
+ | 레이어 | 역할 | 포함 요소 |
444
+ |--------|------|-----------|
445
+ | **Domain** | 핵심 비즈니스 규칙 | Entity, Value Object, Aggregate, Domain Service, Domain Event, Repository 인터페이스 |
446
+ | **Application** | 유스케이스 흐름 조율 | Application Service(Use Case), DTO, 트랜잭션 관리, 이벤트 발행 |
447
+ | **Infrastructure** | 기술적 구현 | Repository 구현, ORM Entity, 외부 API 클라이언트, 메시지 브로커 |
448
+ | **Presentation** | 외부 인터페이스 | Controller, Request/Response DTO, 인증/인가 |
449
+
450
+ ### 디렉토리 예시
451
+
452
+ ```
453
+ src/modules/order/
454
+ ├── domain/
455
+ │ ├── order.ts # Aggregate Root (Entity)
456
+ │ ├── order-item.ts # 내부 Entity
457
+ │ ├── order-status.ts # Value Object / Enum
458
+ │ ├── money.ts # Value Object
459
+ │ ├── order.repository.ts # Repository 인터페이스
460
+ │ ├── order-placed.event.ts # Domain Event
461
+ │ └── pricing.service.ts # Domain Service
462
+ ├── application/
463
+ │ ├── place-order.use-case.ts # Application Service
464
+ │ ├── dto/
465
+ │ │ ├── place-order.command.ts
466
+ │ │ └── order-response.dto.ts
467
+ │ └── event-handlers/
468
+ │ └── send-confirmation.handler.ts
469
+ ├── infrastructure/
470
+ │ ├── persistence/
471
+ │ │ ├── order.orm-entity.ts # ORM Entity (TypeORM)
472
+ │ │ ├── order.mapper.ts # Domain <-> ORM 변환
473
+ │ │ └── typeorm-order.repository.ts
474
+ │ └── external/
475
+ │ └── payment-gateway.client.ts
476
+ └── presentation/
477
+ ├── order.controller.ts
478
+ └── dto/
479
+ ├── create-order.request.ts
480
+ └── order.response.ts
481
+ ```
482
+
483
+ ```typescript
484
+ // Bad - 레이어 경계 무시
485
+ // domain/order.ts에서 TypeORM 데코레이터 사용
486
+ import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
487
+
488
+ @Entity()
489
+ class Order {
490
+ @PrimaryGeneratedColumn('uuid')
491
+ id: string;
492
+
493
+ @Column()
494
+ status: string;
495
+ }
496
+ ```
497
+
498
+ ```typescript
499
+ // Good - 도메인 모델과 ORM Entity 분리
500
+
501
+ // domain/order.ts - 순수 도메인 모델 (인프라 의존성 없음)
502
+ class Order extends AggregateRoot {
503
+ private readonly _id: string;
504
+ private _status: OrderStatus;
505
+
506
+ constructor(id: string, status: OrderStatus) {
507
+ super();
508
+ this._id = id;
509
+ this._status = status;
510
+ }
511
+ // ... 비즈니스 로직
512
+ }
513
+
514
+ // infrastructure/persistence/order.orm-entity.ts - ORM 전용 Entity
515
+ @Entity('orders')
516
+ class OrderOrmEntity {
517
+ @PrimaryGeneratedColumn('uuid')
518
+ id: string;
519
+
520
+ @Column({ type: 'varchar' })
521
+ status: string;
522
+
523
+ @OneToMany(() => OrderItemOrmEntity, (item) => item.order, {
524
+ cascade: ['insert', 'update'],
525
+ })
526
+ items: OrderItemOrmEntity[];
527
+ }
528
+
529
+ // infrastructure/persistence/order.mapper.ts - 변환 로직
530
+ class OrderMapper {
531
+ static toDomain(entity: OrderOrmEntity): Order {
532
+ return new Order(entity.id, entity.status as OrderStatus);
533
+ }
534
+
535
+ static toEntity(domain: Order): OrderOrmEntity {
536
+ const entity = new OrderOrmEntity();
537
+ entity.id = domain.id;
538
+ entity.status = domain.status;
539
+ return entity;
540
+ }
541
+ }
542
+ ```
543
+
544
+ ### 핵심 원칙
545
+ - Domain 레이어는 외부에 의존하지 않는다 (순수 TypeScript)
546
+ - Application 레이어는 Domain의 Entity와 Repository 인터페이스를 사용한다
547
+ - Infrastructure 레이어는 Domain의 인터페이스를 구현한다 (의존성 역전)
548
+ - Presentation 레이어는 Application 레이어의 Use Case를 호출한다
549
+
550
+ ---
551
+
552
+ ## 8. 금지 사항
553
+
554
+ - Entity에 getter/setter만 두고 로직을 Service에 몰아넣기 금지 (Anemic Domain Model)
555
+ - 도메인 레이어에서 인프라 기술(ORM 데코레이터, 프레임워크 데코레이터 등)에 직접 의존 금지
556
+ - Aggregate 간 직접 객체 참조 금지 - ID 참조를 사용한다
557
+ - Aggregate 경계를 넘는 트랜잭션 금지 - 도메인 이벤트로 처리한다
558
+ - Application Service에 도메인 로직 작성 금지 - 도메인 레이어에 위치시킨다
559
+ - 도메인 레이어에서 외부 서비스 직접 호출 금지
560
+
561
+ ---
562
+
563
+ ## 참조 문서
564
+
565
+ - **[Entity & Value Object 심화](./references/entity-vo.md)** - Entity 확장 패턴, Value Object 도메인 연산, Money 패턴 심화
566
+ - **[Aggregate & Repository 심화](./references/aggregate-repository.md)** - Aggregate 내부 Entity 관리, 불변식 보호, Repository 구현 패턴
567
+ - **[Domain Service & Domain Event 심화](./references/domain-events.md)** - 이벤트 발행/구독 패턴, AggregateRoot 기반 이벤트 수집, 핸들러 구현