@choblue/claude-code-toolkit 1.1.4 → 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.
- package/.claude/.project-map-cache +1 -1
- package/.claude/CLAUDE.md +1 -0
- package/.claude/PROJECT_MAP.md +4 -3
- package/.claude/hooks/skill-keywords.conf +2 -1
- package/.claude/skills/Coding/SKILL.md +98 -0
- package/.claude/skills/DDD/SKILL.md +567 -0
- package/.claude/skills/DDD/references/aggregate-repository.md +234 -0
- package/.claude/skills/DDD/references/domain-events.md +202 -0
- package/.claude/skills/DDD/references/entity-vo.md +225 -0
- package/README.md +5 -1
- package/install.sh +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Aggregate & Repository 심화
|
|
2
|
+
|
|
3
|
+
> 이 문서는 `../SKILL.md`의 참조 문서이다.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Aggregate 심화
|
|
8
|
+
|
|
9
|
+
### 내부 Entity (OrderItem) 관리
|
|
10
|
+
|
|
11
|
+
Aggregate Root는 내부 Entity의 생명주기를 완전히 관리한다. 외부에서 내부 Entity를 직접 생성하거나 조작할 수 없다.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// Good - Aggregate 내부 Entity
|
|
15
|
+
class OrderItem {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly _productId: string, // ID로만 참조
|
|
18
|
+
private readonly _price: Money,
|
|
19
|
+
private _quantity: number,
|
|
20
|
+
) {
|
|
21
|
+
if (quantity <= 0) {
|
|
22
|
+
throw new Error('Quantity must be positive');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get productId(): string { return this._productId; }
|
|
27
|
+
get price(): Money { return this._price; }
|
|
28
|
+
get quantity(): number { return this._quantity; }
|
|
29
|
+
|
|
30
|
+
get subtotal(): Money {
|
|
31
|
+
return this._price.multiply(this._quantity);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
increaseQuantity(amount: number): void {
|
|
35
|
+
if (amount <= 0) throw new Error('Amount must be positive');
|
|
36
|
+
this._quantity += amount;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Aggregate Root의 addItem / removeItem 패턴
|
|
42
|
+
|
|
43
|
+
Aggregate Root가 불변식을 보호하면서 내부 Entity를 조작하는 전체 패턴이다.
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
// Good - Aggregate Root가 내부 Entity의 추가/제거를 관리
|
|
47
|
+
class Order {
|
|
48
|
+
private readonly _id: string;
|
|
49
|
+
private readonly _customerId: string;
|
|
50
|
+
private readonly _items: OrderItem[];
|
|
51
|
+
private _status: OrderStatus;
|
|
52
|
+
|
|
53
|
+
constructor(id: string, customerId: string, items: OrderItem[]) {
|
|
54
|
+
if (items.length === 0) {
|
|
55
|
+
throw new Error('Order must have at least one item');
|
|
56
|
+
}
|
|
57
|
+
this._id = id;
|
|
58
|
+
this._customerId = customerId;
|
|
59
|
+
this._items = items;
|
|
60
|
+
this._status = OrderStatus.PENDING;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get id(): string { return this._id; }
|
|
64
|
+
get customerId(): string { return this._customerId; }
|
|
65
|
+
get items(): ReadonlyArray<OrderItem> { return [...this._items]; }
|
|
66
|
+
get status(): OrderStatus { return this._status; }
|
|
67
|
+
|
|
68
|
+
addItem(productId: string, price: Money, quantity: number): void {
|
|
69
|
+
if (this._status !== OrderStatus.PENDING) {
|
|
70
|
+
throw new Error('Cannot modify confirmed order');
|
|
71
|
+
}
|
|
72
|
+
const existing = this._items.find(i => i.productId === productId);
|
|
73
|
+
if (existing) {
|
|
74
|
+
existing.increaseQuantity(quantity);
|
|
75
|
+
} else {
|
|
76
|
+
this._items.push(new OrderItem(productId, price, quantity));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
removeItem(productId: string): void {
|
|
81
|
+
if (this._status !== OrderStatus.PENDING) {
|
|
82
|
+
throw new Error('Cannot modify confirmed order');
|
|
83
|
+
}
|
|
84
|
+
const index = this._items.findIndex(i => i.productId === productId);
|
|
85
|
+
if (index === -1) {
|
|
86
|
+
throw new Error(`Item not found: ${productId}`);
|
|
87
|
+
}
|
|
88
|
+
this._items.splice(index, 1);
|
|
89
|
+
if (this._items.length === 0) {
|
|
90
|
+
throw new Error('Order must have at least one item');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get totalAmount(): Money {
|
|
95
|
+
return this._items.reduce(
|
|
96
|
+
(sum, item) => sum.add(item.subtotal),
|
|
97
|
+
Money.create(0, 'KRW'),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Aggregate 간 ID 참조 패턴
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// Bad - Aggregate 간 직접 객체 참조
|
|
107
|
+
class Order {
|
|
108
|
+
customer: Customer; // 다른 Aggregate를 직접 참조
|
|
109
|
+
items: OrderItem[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class OrderItem {
|
|
113
|
+
product: Product; // 다른 Aggregate를 직접 참조
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Good - ID로만 참조
|
|
117
|
+
class Order {
|
|
118
|
+
private readonly _customerId: string; // ID 참조
|
|
119
|
+
private readonly _items: OrderItem[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class OrderItem {
|
|
123
|
+
private readonly _productId: string; // ID 참조
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Aggregate 설계 원칙
|
|
128
|
+
|
|
129
|
+
| 원칙 | 설명 |
|
|
130
|
+
|------|------|
|
|
131
|
+
| 작게 유지 | 꼭 필요한 Entity만 포함. 크기가 커지면 분리 검토 |
|
|
132
|
+
| ID 참조 | Aggregate 간 객체 참조 대신 ID 참조 사용 |
|
|
133
|
+
| 하나의 트랜잭션 | 하나의 트랜잭션에서 하나의 Aggregate만 변경 |
|
|
134
|
+
| 불변식 보호 | 모든 상태 변경 시 비즈니스 규칙(불변식) 검증 |
|
|
135
|
+
| Root를 통한 접근 | 외부에서 내부 Entity에 직접 접근 불가 |
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 2. Repository 심화
|
|
140
|
+
|
|
141
|
+
### Repository 인터페이스와 구현 분리
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// domain/order/order.repository.ts (도메인 레이어 - 인터페이스만)
|
|
145
|
+
interface OrderRepository {
|
|
146
|
+
findById(id: string): Promise<Order | null>;
|
|
147
|
+
findByCustomerId(customerId: string): Promise<Order[]>;
|
|
148
|
+
save(order: Order): Promise<void>;
|
|
149
|
+
delete(id: string): Promise<void>;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### TypeORM 기반 구현체
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// infrastructure/persistence/typeorm-order.repository.ts (인프라 레이어 - 구현)
|
|
157
|
+
@Injectable()
|
|
158
|
+
class TypeOrmOrderRepository implements OrderRepository {
|
|
159
|
+
constructor(
|
|
160
|
+
@InjectRepository(OrderEntity)
|
|
161
|
+
private readonly ormRepo: Repository<OrderEntity>,
|
|
162
|
+
) {}
|
|
163
|
+
|
|
164
|
+
async findById(id: string): Promise<Order | null> {
|
|
165
|
+
const entity = await this.ormRepo.findOne({
|
|
166
|
+
where: { id },
|
|
167
|
+
relations: ['items'],
|
|
168
|
+
});
|
|
169
|
+
if (!entity) return null;
|
|
170
|
+
return OrderMapper.toDomain(entity);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async save(order: Order): Promise<void> {
|
|
174
|
+
const entity = OrderMapper.toEntity(order);
|
|
175
|
+
await this.ormRepo.save(entity);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async findByCustomerId(customerId: string): Promise<Order[]> {
|
|
179
|
+
const entities = await this.ormRepo.find({
|
|
180
|
+
where: { customerId },
|
|
181
|
+
relations: ['items'],
|
|
182
|
+
});
|
|
183
|
+
return entities.map(OrderMapper.toDomain);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async delete(id: string): Promise<void> {
|
|
187
|
+
await this.ormRepo.delete(id);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Mapper 패턴
|
|
193
|
+
|
|
194
|
+
도메인 모델과 ORM Entity 간 변환을 담당한다.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
// infrastructure/persistence/order.mapper.ts
|
|
198
|
+
class OrderMapper {
|
|
199
|
+
static toDomain(entity: OrderOrmEntity): Order {
|
|
200
|
+
return new Order(entity.id, entity.status as OrderStatus);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
static toEntity(domain: Order): OrderOrmEntity {
|
|
204
|
+
const entity = new OrderOrmEntity();
|
|
205
|
+
entity.id = domain.id;
|
|
206
|
+
entity.status = domain.status;
|
|
207
|
+
return entity;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### NestJS Module에서 DI 바인딩
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
// Module에서 DI 바인딩
|
|
216
|
+
@Module({
|
|
217
|
+
providers: [
|
|
218
|
+
{
|
|
219
|
+
provide: 'OrderRepository', // 또는 Symbol/abstract class 토큰
|
|
220
|
+
useClass: TypeOrmOrderRepository,
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
})
|
|
224
|
+
class OrderModule {}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Repository 설계 원칙
|
|
228
|
+
|
|
229
|
+
| 원칙 | 설명 |
|
|
230
|
+
|------|------|
|
|
231
|
+
| Aggregate Root 단위 | OrderItem용 Repository는 만들지 않는다 |
|
|
232
|
+
| 인터페이스 분리 | 도메인 레이어에 인터페이스, 인프라 레이어에 구현 |
|
|
233
|
+
| Mapper 사용 | 도메인 모델과 ORM Entity를 반드시 분리한다 |
|
|
234
|
+
| 의존성 역전 | Service는 인터페이스에 의존, 구현체는 DI로 주입 |
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Domain Service & Domain Event 심화
|
|
2
|
+
|
|
3
|
+
> 이 문서는 `../SKILL.md`의 참조 문서이다.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Domain Service 심화
|
|
8
|
+
|
|
9
|
+
### 여러 Aggregate 간 계산 로직
|
|
10
|
+
|
|
11
|
+
Domain Service는 단일 Entity에 속하지 않는, 여러 Aggregate를 조율하는 도메인 로직을 담당한다.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// Bad - 특정 Entity에 속하는 로직을 Domain Service에 두기
|
|
15
|
+
class OrderDomainService {
|
|
16
|
+
// 이 로직은 Order Entity에 속해야 한다
|
|
17
|
+
cancelOrder(order: Order): void {
|
|
18
|
+
if (order.status !== OrderStatus.PENDING) {
|
|
19
|
+
throw new Error('Cannot cancel');
|
|
20
|
+
}
|
|
21
|
+
order.status = OrderStatus.CANCELLED;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 이 로직도 Order Entity에 속해야 한다
|
|
25
|
+
calculateTotal(order: Order): number {
|
|
26
|
+
return order.items.reduce(
|
|
27
|
+
(sum, item) => sum + item.price * item.quantity,
|
|
28
|
+
0,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 할인 계산 Domain Service
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// Good - 여러 Aggregate(Order, Customer, Promotion)의 정보가 필요한 할인 계산
|
|
38
|
+
class PricingService {
|
|
39
|
+
calculateDiscount(
|
|
40
|
+
order: Order,
|
|
41
|
+
customerTier: CustomerTier,
|
|
42
|
+
promotions: Promotion[],
|
|
43
|
+
): Money {
|
|
44
|
+
let discount = Money.create(0, 'KRW');
|
|
45
|
+
|
|
46
|
+
// 고객 등급별 할인
|
|
47
|
+
if (customerTier === CustomerTier.VIP) {
|
|
48
|
+
discount = discount.add(order.totalAmount.multiply(0.1));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 프로모션 할인
|
|
52
|
+
for (const promo of promotions) {
|
|
53
|
+
if (promo.isApplicableTo(order)) {
|
|
54
|
+
discount = discount.add(promo.calculateDiscount(order.totalAmount));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return discount;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 이체 Domain Service
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
// Good - 두 Aggregate(Account) 간 이체 - 어느 한쪽에 속하지 않는 로직
|
|
67
|
+
class TransferService {
|
|
68
|
+
transfer(from: Account, to: Account, amount: Money): void {
|
|
69
|
+
if (!from.canWithdraw(amount)) {
|
|
70
|
+
throw new Error('Insufficient balance');
|
|
71
|
+
}
|
|
72
|
+
from.withdraw(amount);
|
|
73
|
+
to.deposit(amount);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Domain Service vs Application Service 구분
|
|
79
|
+
|
|
80
|
+
| 구분 | Domain Service | Application Service |
|
|
81
|
+
|------|---------------|-------------------|
|
|
82
|
+
| 역할 | 순수 도메인 로직 | 유스케이스 흐름 조율 |
|
|
83
|
+
| 예시 | 할인 계산, 이체 규칙 | 트랜잭션, 이벤트 발행 |
|
|
84
|
+
| 상태 | stateless | stateless |
|
|
85
|
+
| 의존성 | 도메인 객체만 | Repository, EventPublisher 등 |
|
|
86
|
+
| 위치 | Domain 레이어 | Application 레이어 |
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 2. Domain Event 심화
|
|
91
|
+
|
|
92
|
+
### AggregateRoot 기반 이벤트 수집
|
|
93
|
+
|
|
94
|
+
Aggregate에서 이벤트를 수집하는 기반 클래스를 정의한다.
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// Good - AggregateRoot 기반 클래스
|
|
98
|
+
abstract class AggregateRoot {
|
|
99
|
+
private _domainEvents: DomainEvent[] = [];
|
|
100
|
+
|
|
101
|
+
get domainEvents(): ReadonlyArray<DomainEvent> {
|
|
102
|
+
return [...this._domainEvents];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
protected addDomainEvent(event: DomainEvent): void {
|
|
106
|
+
this._domainEvents.push(event);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
clearDomainEvents(): void {
|
|
110
|
+
this._domainEvents = [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Aggregate에서 이벤트 발생
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// Good - Order Aggregate가 생성 시 이벤트를 수집
|
|
119
|
+
class Order extends AggregateRoot {
|
|
120
|
+
// ... 기존 필드 생략
|
|
121
|
+
|
|
122
|
+
static place(id: string, customerId: string, items: OrderItem[]): Order {
|
|
123
|
+
const order = new Order(id, customerId, items);
|
|
124
|
+
order.addDomainEvent(
|
|
125
|
+
new OrderPlacedEvent(
|
|
126
|
+
order.id,
|
|
127
|
+
order.customerId,
|
|
128
|
+
order.items.map(i => ({ productId: i.productId, quantity: i.quantity })),
|
|
129
|
+
order.totalAmount.amount,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
return order;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### 이벤트 핸들러 독립 구현
|
|
138
|
+
|
|
139
|
+
각 핸들러는 독립적으로 이벤트를 처리하며, 핸들러 간 순서 의존성이 없다.
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// Good - 각 핸들러가 독립적으로 처리
|
|
143
|
+
class SendOrderConfirmationHandler {
|
|
144
|
+
constructor(private readonly emailService: EmailService) {}
|
|
145
|
+
|
|
146
|
+
async handle(event: OrderPlacedEvent): Promise<void> {
|
|
147
|
+
await this.emailService.sendOrderConfirmation(event.orderId);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class DecreaseStockHandler {
|
|
152
|
+
constructor(private readonly inventoryService: InventoryService) {}
|
|
153
|
+
|
|
154
|
+
async handle(event: OrderPlacedEvent): Promise<void> {
|
|
155
|
+
for (const item of event.items) {
|
|
156
|
+
await this.inventoryService.decrease(item.productId, item.quantity);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
class AccumulatePointsHandler {
|
|
162
|
+
constructor(private readonly pointService: PointService) {}
|
|
163
|
+
|
|
164
|
+
async handle(event: OrderPlacedEvent): Promise<void> {
|
|
165
|
+
await this.pointService.accumulate(event.customerId, event.totalAmount);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Application Service에서의 조합
|
|
171
|
+
|
|
172
|
+
Application Service가 Repository 저장과 이벤트 발행을 조율한다.
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// Good - Application Service가 유스케이스 흐름을 조율
|
|
176
|
+
class PlaceOrderUseCase {
|
|
177
|
+
constructor(
|
|
178
|
+
private readonly orderRepo: OrderRepository,
|
|
179
|
+
private readonly eventPublisher: DomainEventPublisher,
|
|
180
|
+
) {}
|
|
181
|
+
|
|
182
|
+
async execute(command: PlaceOrderCommand): Promise<void> {
|
|
183
|
+
const order = Order.place(command.id, command.customerId, command.items);
|
|
184
|
+
await this.orderRepo.save(order);
|
|
185
|
+
|
|
186
|
+
// Aggregate에서 수집한 이벤트를 발행
|
|
187
|
+
await this.eventPublisher.publishAll(order.domainEvents);
|
|
188
|
+
order.clearDomainEvents();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### 이벤트 설계 원칙
|
|
194
|
+
|
|
195
|
+
| 원칙 | 설명 |
|
|
196
|
+
|------|------|
|
|
197
|
+
| 과거형 이름 | `OrderPlaced`, `PaymentCompleted` (not `PlaceOrder`) |
|
|
198
|
+
| 불변 객체 | 생성 후 변경하지 않는다 |
|
|
199
|
+
| 필요한 데이터만 | 이벤트에 Aggregate 전체를 넣지 않고, 핸들러가 필요한 최소 데이터만 포함 |
|
|
200
|
+
| 발생 시각 포함 | `occurredAt` 필드로 이벤트 발생 시각을 기록 |
|
|
201
|
+
| 순서 비의존 | 핸들러 간 실행 순서에 의존하지 않는다 |
|
|
202
|
+
| Aggregate에서 수집 | `addDomainEvent`로 수집, Application 레이어에서 `publishAll`로 발행 |
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Entity & Value Object 심화
|
|
2
|
+
|
|
3
|
+
> 이 문서는 `../SKILL.md`의 참조 문서이다.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Entity 심화
|
|
8
|
+
|
|
9
|
+
### Value Object를 활용한 Entity 강화
|
|
10
|
+
|
|
11
|
+
Entity 내부에서 Value Object를 사용하여 도메인 개념을 명확히 표현한다.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// Good - Entity가 Value Object를 내부적으로 활용
|
|
15
|
+
class Order {
|
|
16
|
+
private readonly _id: string;
|
|
17
|
+
private _status: OrderStatus;
|
|
18
|
+
private readonly _items: OrderItem[];
|
|
19
|
+
|
|
20
|
+
constructor(id: string, items: OrderItem[]) {
|
|
21
|
+
if (items.length === 0) {
|
|
22
|
+
throw new Error('Order must have at least one item');
|
|
23
|
+
}
|
|
24
|
+
this._id = id;
|
|
25
|
+
this._status = OrderStatus.PENDING;
|
|
26
|
+
this._items = items;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get id(): string { return this._id; }
|
|
30
|
+
get status(): OrderStatus { return this._status; }
|
|
31
|
+
get items(): ReadonlyArray<OrderItem> { return [...this._items]; }
|
|
32
|
+
|
|
33
|
+
get totalAmount(): Money {
|
|
34
|
+
return this._items.reduce(
|
|
35
|
+
(sum, item) => sum.add(item.subtotal),
|
|
36
|
+
Money.create(0, 'KRW'),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
cancel(): void {
|
|
41
|
+
if (this._status !== OrderStatus.PENDING) {
|
|
42
|
+
throw new Error('Only pending orders can be cancelled');
|
|
43
|
+
}
|
|
44
|
+
this._status = OrderStatus.CANCELLED;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
confirm(): void {
|
|
48
|
+
if (this._status !== OrderStatus.PENDING) {
|
|
49
|
+
throw new Error('Only pending orders can be confirmed');
|
|
50
|
+
}
|
|
51
|
+
this._status = OrderStatus.CONFIRMED;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
equals(other: Order): boolean {
|
|
55
|
+
return this._id === other._id;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### equals 메서드 패턴
|
|
61
|
+
|
|
62
|
+
Entity의 동등성 비교는 반드시 id 기반으로 한다.
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// Bad - 모든 속성 비교
|
|
66
|
+
class User {
|
|
67
|
+
equals(other: User): boolean {
|
|
68
|
+
return this.name === other.name && this.email === other.email;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Good - id 기반 비교
|
|
73
|
+
class User {
|
|
74
|
+
private readonly _id: string;
|
|
75
|
+
|
|
76
|
+
equals(other: User): boolean {
|
|
77
|
+
return this._id === other._id;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 상태 전이(State Transition) 패턴
|
|
83
|
+
|
|
84
|
+
Entity의 상태 변경은 명시적인 메서드로 표현하고, 허용되지 않는 전이를 방어한다.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// Good - 상태 전이를 명시적으로 관리
|
|
88
|
+
class Order {
|
|
89
|
+
private _status: OrderStatus;
|
|
90
|
+
|
|
91
|
+
// 허용 전이: PENDING -> CONFIRMED -> SHIPPED -> DELIVERED
|
|
92
|
+
// PENDING -> CANCELLED
|
|
93
|
+
|
|
94
|
+
confirm(): void {
|
|
95
|
+
this.assertStatus(OrderStatus.PENDING, 'confirm');
|
|
96
|
+
this._status = OrderStatus.CONFIRMED;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ship(): void {
|
|
100
|
+
this.assertStatus(OrderStatus.CONFIRMED, 'ship');
|
|
101
|
+
this._status = OrderStatus.SHIPPED;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
deliver(): void {
|
|
105
|
+
this.assertStatus(OrderStatus.SHIPPED, 'deliver');
|
|
106
|
+
this._status = OrderStatus.DELIVERED;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
cancel(): void {
|
|
110
|
+
if (this._status !== OrderStatus.PENDING) {
|
|
111
|
+
throw new Error('Only pending orders can be cancelled');
|
|
112
|
+
}
|
|
113
|
+
this._status = OrderStatus.CANCELLED;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private assertStatus(expected: OrderStatus, action: string): void {
|
|
117
|
+
if (this._status !== expected) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Cannot ${action} order in ${this._status} status (expected: ${expected})`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 2. Value Object 심화
|
|
129
|
+
|
|
130
|
+
### Money 패턴 전체 구현
|
|
131
|
+
|
|
132
|
+
Money는 대표적인 Value Object로, 금액과 통화를 함께 관리한다.
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// Good - Money Value Object 전체 구현
|
|
136
|
+
class Money {
|
|
137
|
+
private constructor(
|
|
138
|
+
private readonly _amount: number,
|
|
139
|
+
private readonly _currency: string,
|
|
140
|
+
) {}
|
|
141
|
+
|
|
142
|
+
static create(amount: number, currency: string): Money {
|
|
143
|
+
if (amount < 0) {
|
|
144
|
+
throw new Error('Amount cannot be negative');
|
|
145
|
+
}
|
|
146
|
+
if (!['KRW', 'USD', 'EUR'].includes(currency)) {
|
|
147
|
+
throw new Error(`Unsupported currency: ${currency}`);
|
|
148
|
+
}
|
|
149
|
+
return new Money(amount, currency);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
get amount(): number { return this._amount; }
|
|
153
|
+
get currency(): string { return this._currency; }
|
|
154
|
+
|
|
155
|
+
add(other: Money): Money {
|
|
156
|
+
if (this._currency !== other._currency) {
|
|
157
|
+
throw new Error('Cannot add different currencies');
|
|
158
|
+
}
|
|
159
|
+
return Money.create(this._amount + other._amount, this._currency);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
multiply(factor: number): Money {
|
|
163
|
+
return Money.create(this._amount * factor, this._currency);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
equals(other: Money): boolean {
|
|
167
|
+
return this._amount === other._amount && this._currency === other._currency;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### 복합 Value Object
|
|
173
|
+
|
|
174
|
+
여러 속성을 가진 Value Object도 동일한 패턴을 따른다.
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
// Good - 복합 Value Object (주소)
|
|
178
|
+
class Address {
|
|
179
|
+
private constructor(
|
|
180
|
+
private readonly _street: string,
|
|
181
|
+
private readonly _city: string,
|
|
182
|
+
private readonly _zipCode: string,
|
|
183
|
+
private readonly _country: string,
|
|
184
|
+
) {}
|
|
185
|
+
|
|
186
|
+
static create(street: string, city: string, zipCode: string, country: string): Address {
|
|
187
|
+
if (!street || !city || !zipCode || !country) {
|
|
188
|
+
throw new Error('All address fields are required');
|
|
189
|
+
}
|
|
190
|
+
if (!/^\d{5}$/.test(zipCode)) {
|
|
191
|
+
throw new Error(`Invalid zip code: ${zipCode}`);
|
|
192
|
+
}
|
|
193
|
+
return new Address(street, city, zipCode, country);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
get street(): string { return this._street; }
|
|
197
|
+
get city(): string { return this._city; }
|
|
198
|
+
get zipCode(): string { return this._zipCode; }
|
|
199
|
+
get country(): string { return this._country; }
|
|
200
|
+
|
|
201
|
+
equals(other: Address): boolean {
|
|
202
|
+
return (
|
|
203
|
+
this._street === other._street &&
|
|
204
|
+
this._city === other._city &&
|
|
205
|
+
this._zipCode === other._zipCode &&
|
|
206
|
+
this._country === other._country
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 상태 변경 시 새 인스턴스 반환 (불변 유지)
|
|
211
|
+
changeStreet(newStreet: string): Address {
|
|
212
|
+
return Address.create(newStreet, this._city, this._zipCode, this._country);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Value Object vs 원시값 판단 기준
|
|
218
|
+
|
|
219
|
+
| 기준 | 원시값 사용 | Value Object 사용 |
|
|
220
|
+
|------|------------|-------------------|
|
|
221
|
+
| 유효성 검증 | 필요 없음 | 생성 시 검증 필요 |
|
|
222
|
+
| 도메인 연산 | 없음 | add, multiply 등 존재 |
|
|
223
|
+
| 포맷/정규화 | 불필요 | trim, lowercase 등 필요 |
|
|
224
|
+
| 여러 속성 조합 | 단일 값 | 복합 값 (Money = amount + currency) |
|
|
225
|
+
| 비즈니스 의미 | 범용적 | 도메인 특화 의미 |
|
package/README.md
CHANGED
|
@@ -42,7 +42,11 @@
|
|
|
42
42
|
│ ├── TypeScript/
|
|
43
43
|
│ │ └── SKILL.md ← TypeScript 고급 패턴
|
|
44
44
|
│ ├── TypeORM/
|
|
45
|
-
│ │
|
|
45
|
+
│ │ ├── SKILL.md ← TypeORM Entity, Repository
|
|
46
|
+
│ │ └── references/ ← 고급 쿼리, 마이그레이션, 트랜잭션
|
|
47
|
+
│ ├── DDD/
|
|
48
|
+
│ │ ├── SKILL.md ← DDD 전술적 패턴
|
|
49
|
+
│ │ └── references/ ← Entity/VO, Aggregate, Domain Event 심화
|
|
46
50
|
│ ├── TDD/
|
|
47
51
|
│ │ ├── SKILL.md ← TDD 핵심 원칙
|
|
48
52
|
│ │ ├── frontend.md ← React 테스트 규칙
|
package/install.sh
CHANGED