@mandujs/core 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -25,11 +25,16 @@ bun add @mandujs/core
25
25
 
26
26
  ```
27
27
  @mandujs/core
28
- ├── spec/ # Spec 스키마 로딩
29
- ├── generator/ # 코드 생성
28
+ ├── router/ # 파일 시스템 기반 라우팅
30
29
  ├── guard/ # 아키텍처 검사 및 자동 수정
31
- ├── runtime/ # 서버 라우터
32
- └── report/ # Guard 리포트 생성
30
+ ├── runtime/ # 서버, SSR, 스트리밍
31
+ ├── filling/ # 핸들러 체인 API
32
+ ├── contract/ # 타입 안전 API 계약
33
+ ├── content/ # Content Layer - 빌드 타임 콘텐츠 로딩 🆕
34
+ ├── bundler/ # 클라이언트 번들링, HMR
35
+ ├── client/ # Island 하이드레이션, 클라이언트 라우터
36
+ ├── brain/ # Doctor, Watcher, 아키텍처 분석
37
+ └── change/ # 트랜잭션 & 히스토리
33
38
  ```
34
39
 
35
40
  ## Spec 모듈
@@ -133,6 +138,73 @@ if (!result.passed) {
133
138
  | `COMPONENT_NOT_FOUND` | 컴포넌트 파일 없음 | ❌ |
134
139
  | `SLOT_NOT_FOUND` | slot 파일 없음 | ✅ |
135
140
 
141
+ ## Content Layer 🆕
142
+
143
+ Astro에서 영감받은 빌드 타임 콘텐츠 로딩 시스템.
144
+
145
+ ```typescript
146
+ // content.config.ts
147
+ import { defineContentConfig, glob, file, api } from "@mandujs/core/content";
148
+ import { z } from "zod";
149
+
150
+ const postSchema = z.object({
151
+ title: z.string(),
152
+ date: z.coerce.date(),
153
+ tags: z.array(z.string()).default([]),
154
+ });
155
+
156
+ export default defineContentConfig({
157
+ collections: {
158
+ // Markdown 파일 (프론트매터 지원)
159
+ posts: {
160
+ loader: glob({ pattern: "content/posts/**/*.md" }),
161
+ schema: postSchema,
162
+ },
163
+ // 단일 JSON/YAML 파일
164
+ settings: {
165
+ loader: file({ path: "data/settings.json" }),
166
+ },
167
+ // 외부 API
168
+ products: {
169
+ loader: api({
170
+ url: "https://api.example.com/products",
171
+ cacheTTL: 3600,
172
+ }),
173
+ },
174
+ },
175
+ });
176
+ ```
177
+
178
+ ### 콘텐츠 조회
179
+
180
+ ```typescript
181
+ import { getCollection, getEntry } from "@mandujs/core/content";
182
+
183
+ // 전체 컬렉션 조회
184
+ const posts = await getCollection("posts");
185
+
186
+ // 단일 엔트리 조회
187
+ const post = await getEntry("posts", "hello-world");
188
+ console.log(post?.data.title, post?.body);
189
+ ```
190
+
191
+ ### 내장 로더
192
+
193
+ | 로더 | 설명 | 예시 |
194
+ |------|------|------|
195
+ | `file()` | 단일 파일 (JSON, YAML, TOML) | `file({ path: "data/config.json" })` |
196
+ | `glob()` | 패턴 매칭 (Markdown, JSON) | `glob({ pattern: "content/**/*.md" })` |
197
+ | `api()` | HTTP API (캐싱 지원) | `api({ url: "https://...", cacheTTL: 3600 })` |
198
+
199
+ ### 주요 기능
200
+
201
+ - **Digest 기반 캐싱**: 변경된 파일만 재파싱
202
+ - **Zod 검증**: 스키마 기반 타입 안전 콘텐츠
203
+ - **프론트매터 지원**: Markdown YAML 프론트매터
204
+ - **Dev 모드 감시**: 콘텐츠 변경 시 자동 리로드
205
+
206
+ ---
207
+
136
208
  ## Contract 모듈
137
209
 
138
210
  Zod 기반 계약(Contract) 정의 및 타입 안전 클라이언트 생성.
@@ -209,6 +281,11 @@ import type {
209
281
  GuardViolation,
210
282
  GenerateResult,
211
283
  AutoCorrectResult,
284
+ // Content Layer
285
+ DataEntry,
286
+ ContentConfig,
287
+ CollectionConfig,
288
+ Loader,
212
289
  } from "@mandujs/core";
213
290
  ```
214
291
 
package/README.md CHANGED
@@ -81,6 +81,8 @@ const handlers = Mandu.handler(userContract, {
81
81
  ├── runtime/ # Server, SSR, streaming
82
82
  ├── filling/ # Handler chain API (Mandu.filling())
83
83
  ├── contract/ # Type-safe API contracts
84
+ ├── content/ # Content Layer - build-time content loading 🆕
85
+ │ └── loaders # file(), glob(), api() loaders
84
86
  ├── bundler/ # Client bundling, HMR
85
87
  ├── client/ # Island hydration, client router
86
88
  ├── brain/ # Doctor, Watcher, Architecture analyzer
@@ -453,6 +455,78 @@ function Navigation() {
453
455
 
454
456
  ---
455
457
 
458
+ ## Content Layer 🆕
459
+
460
+ Astro-inspired build-time content loading system.
461
+
462
+ ```typescript
463
+ // content.config.ts
464
+ import { defineContentConfig, glob, file, api } from "@mandujs/core/content";
465
+ import { z } from "zod";
466
+
467
+ const postSchema = z.object({
468
+ title: z.string(),
469
+ date: z.coerce.date(),
470
+ tags: z.array(z.string()).default([]),
471
+ });
472
+
473
+ export default defineContentConfig({
474
+ collections: {
475
+ // Markdown files with frontmatter
476
+ posts: {
477
+ loader: glob({ pattern: "content/posts/**/*.md" }),
478
+ schema: postSchema,
479
+ },
480
+ // Single JSON/YAML file
481
+ settings: {
482
+ loader: file({ path: "data/settings.json" }),
483
+ },
484
+ // External API
485
+ products: {
486
+ loader: api({
487
+ url: "https://api.example.com/products",
488
+ headers: () => ({ Authorization: `Bearer ${process.env.API_KEY}` }),
489
+ cacheTTL: 3600,
490
+ }),
491
+ },
492
+ },
493
+ });
494
+ ```
495
+
496
+ ### Querying Content
497
+
498
+ ```typescript
499
+ import { getCollection, getEntry } from "@mandujs/core/content";
500
+
501
+ // Get all entries
502
+ const posts = await getCollection("posts");
503
+ posts.forEach(post => {
504
+ console.log(post.id, post.data.title);
505
+ });
506
+
507
+ // Get single entry
508
+ const post = await getEntry("posts", "hello-world");
509
+ console.log(post?.data.title, post?.body);
510
+ ```
511
+
512
+ ### Built-in Loaders
513
+
514
+ | Loader | Description | Example |
515
+ |--------|-------------|---------|
516
+ | `file()` | Single file (JSON, YAML, TOML) | `file({ path: "data/config.json" })` |
517
+ | `glob()` | Pattern matching (Markdown, JSON) | `glob({ pattern: "content/**/*.md" })` |
518
+ | `api()` | HTTP API with caching | `api({ url: "https://...", cacheTTL: 3600 })` |
519
+
520
+ ### Features
521
+
522
+ - **Digest-based caching**: Only re-parse changed files
523
+ - **Zod validation**: Type-safe content with schema validation
524
+ - **Frontmatter support**: YAML frontmatter in Markdown files
525
+ - **Dev mode watching**: Auto-reload on content changes
526
+ - **Incremental updates**: Efficient builds with change detection
527
+
528
+ ---
529
+
456
530
  ## Brain (AI Assistant)
457
531
 
458
532
  Doctor and architecture analyzer.
@@ -551,6 +625,13 @@ import type {
551
625
 
552
626
  // Filling
553
627
  ManduContext,
628
+
629
+ // Content Layer
630
+ DataEntry,
631
+ ContentConfig,
632
+ CollectionConfig,
633
+ Loader,
634
+ LoaderContext,
554
635
  } from "@mandujs/core";
555
636
  ```
556
637
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -35,7 +35,7 @@
35
35
  "directory": "packages/core"
36
36
  },
37
37
  "author": "konamgil",
38
- "license": "MIT",
38
+ "license": "MPL-2.0",
39
39
  "publishConfig": {
40
40
  "access": "public"
41
41
  },
@@ -669,6 +669,8 @@ function getLayerHierarchy(preset?: GuardPreset): string {
669
669
  return "adapters → ports → domain";
670
670
  case "atomic":
671
671
  return "pages → templates → organisms → molecules → atoms";
672
+ case "cqrs":
673
+ return "api → commands|queries → dto/events → domain → shared";
672
674
  case "mandu":
673
675
  return "client(FSD) | shared | server(Clean)";
674
676
  default:
@@ -139,11 +139,13 @@ export {
139
139
  cleanPreset,
140
140
  hexagonalPreset,
141
141
  atomicPreset,
142
+ cqrsPreset,
142
143
  manduPreset,
143
144
  FSD_HIERARCHY,
144
145
  CLEAN_HIERARCHY,
145
146
  HEXAGONAL_HIERARCHY,
146
147
  ATOMIC_HIERARCHY,
148
+ CQRS_HIERARCHY,
147
149
  } from "./presets";
148
150
 
149
151
  // ═══════════════════════════════════════════════════════════════════════════
@@ -118,7 +118,11 @@ export type FileTemplate =
118
118
  | "util"
119
119
  | "type"
120
120
  | "test"
121
- | "slot";
121
+ | "slot"
122
+ | "command"
123
+ | "query"
124
+ | "event"
125
+ | "dto";
122
126
 
123
127
  /**
124
128
  * 협상 응답
@@ -438,6 +442,341 @@ const STRUCTURE_TEMPLATES: Record<FeatureCategory, (featureName: string) => Dire
438
442
  ],
439
443
  };
440
444
 
445
+ // ═══════════════════════════════════════════════════════════════════════════
446
+ // CQRS Structure Templates
447
+ // ═══════════════════════════════════════════════════════════════════════════
448
+
449
+ /**
450
+ * CQRS 프리셋 전용 구조 템플릿
451
+ *
452
+ * application 레이어를 commands/queries/dto/events/mappers로 세분화
453
+ */
454
+ const CQRS_STRUCTURE_TEMPLATES: Record<FeatureCategory, (featureName: string) => DirectoryProposal[]> = {
455
+ auth: (name) => [
456
+ {
457
+ path: `src/domain/${name}`,
458
+ purpose: `${name} 도메인 모델`,
459
+ layer: "domain",
460
+ files: [
461
+ { name: `${name}.entity.ts`, purpose: "사용자/인증 엔티티", template: "type" },
462
+ { name: `${name}.service.ts`, purpose: "도메인 서비스 인터페이스", template: "service" },
463
+ { name: `${name}.repository.ts`, purpose: "Repository 인터페이스", template: "repository" },
464
+ ],
465
+ },
466
+ {
467
+ path: `src/application/commands/${name}`,
468
+ purpose: `${name} 쓰기 경로`,
469
+ layer: "application/commands",
470
+ files: [
471
+ { name: `login.command.ts`, purpose: "로그인 커맨드 핸들러", template: "command" },
472
+ { name: `logout.command.ts`, purpose: "로그아웃 커맨드 핸들러", template: "command" },
473
+ { name: `refresh-token.command.ts`, purpose: "토큰 갱신 커맨드 핸들러", template: "command" },
474
+ ],
475
+ },
476
+ {
477
+ path: `src/application/queries/${name}`,
478
+ purpose: `${name} 읽기 경로`,
479
+ layer: "application/queries",
480
+ files: [
481
+ { name: `get-session.query.ts`, purpose: "세션 조회 쿼리 핸들러", template: "query" },
482
+ { name: `verify-token.query.ts`, purpose: "토큰 검증 쿼리 핸들러", template: "query" },
483
+ ],
484
+ },
485
+ {
486
+ path: `src/application/dto/${name}`,
487
+ purpose: `${name} DTO`,
488
+ layer: "application/dto",
489
+ files: [
490
+ { name: `login.dto.ts`, purpose: "로그인 요청/응답 DTO", template: "dto" },
491
+ { name: `token.dto.ts`, purpose: "토큰 DTO", template: "dto" },
492
+ ],
493
+ },
494
+ {
495
+ path: `src/application/events/${name}`,
496
+ purpose: `${name} 도메인 이벤트`,
497
+ layer: "application/events",
498
+ files: [
499
+ { name: `user-logged-in.event.ts`, purpose: "로그인 성공 이벤트", template: "event" },
500
+ { name: `user-logged-out.event.ts`, purpose: "로그아웃 이벤트", template: "event" },
501
+ ],
502
+ },
503
+ {
504
+ path: `src/infra/${name}`,
505
+ purpose: `${name} 인프라 어댑터`,
506
+ layer: "infrastructure",
507
+ files: [
508
+ { name: `token.provider.ts`, purpose: "토큰 생성/검증 구현", template: "service" },
509
+ { name: `session.repository.ts`, purpose: "세션 저장소 구현", template: "repository" },
510
+ ],
511
+ },
512
+ {
513
+ path: `src/api/${name}`,
514
+ purpose: `${name} API 라우트`,
515
+ layer: "api",
516
+ files: [
517
+ { name: `login/route.ts`, purpose: "로그인 API → LoginCommand 디스패치", template: "route", isSlot: true },
518
+ { name: `logout/route.ts`, purpose: "로그아웃 API → LogoutCommand 디스패치", template: "route", isSlot: true },
519
+ { name: `refresh/route.ts`, purpose: "토큰 갱신 API → RefreshTokenCommand 디스패치", template: "route", isSlot: true },
520
+ { name: `session/route.ts`, purpose: "세션 조회 API → GetSessionQuery 디스패치", template: "route", isSlot: true },
521
+ ],
522
+ },
523
+ ],
524
+
525
+ crud: (name) => [
526
+ {
527
+ path: `src/domain/${name}`,
528
+ purpose: `${name} 도메인`,
529
+ layer: "domain",
530
+ files: [
531
+ { name: `${name}.entity.ts`, purpose: "엔티티 정의", template: "type" },
532
+ { name: `${name}.repository.ts`, purpose: "Repository 인터페이스", template: "repository" },
533
+ ],
534
+ },
535
+ {
536
+ path: `src/application/commands/${name}`,
537
+ purpose: `${name} 쓰기 커맨드`,
538
+ layer: "application/commands",
539
+ files: [
540
+ { name: `create-${name}.command.ts`, purpose: "생성 커맨드 핸들러", template: "command" },
541
+ { name: `update-${name}.command.ts`, purpose: "수정 커맨드 핸들러", template: "command" },
542
+ { name: `delete-${name}.command.ts`, purpose: "삭제 커맨드 핸들러", template: "command" },
543
+ ],
544
+ },
545
+ {
546
+ path: `src/application/queries/${name}`,
547
+ purpose: `${name} 읽기 쿼리`,
548
+ layer: "application/queries",
549
+ files: [
550
+ { name: `get-${name}.query.ts`, purpose: "단건 조회 쿼리 핸들러", template: "query" },
551
+ { name: `list-${name}.query.ts`, purpose: "목록 조회 쿼리 핸들러", template: "query" },
552
+ ],
553
+ },
554
+ {
555
+ path: `src/application/dto/${name}`,
556
+ purpose: `${name} DTO`,
557
+ layer: "application/dto",
558
+ files: [
559
+ { name: `create-${name}.dto.ts`, purpose: "생성 요청 DTO", template: "dto" },
560
+ { name: `update-${name}.dto.ts`, purpose: "수정 요청 DTO", template: "dto" },
561
+ { name: `${name}-response.dto.ts`, purpose: "응답 DTO", template: "dto" },
562
+ ],
563
+ },
564
+ {
565
+ path: `src/infra/${name}`,
566
+ purpose: `${name} Repository 구현`,
567
+ layer: "infrastructure",
568
+ files: [
569
+ { name: `${name}.repository-impl.ts`, purpose: "Repository 구현체", template: "repository" },
570
+ ],
571
+ },
572
+ {
573
+ path: `src/api/${name}`,
574
+ purpose: `${name} API`,
575
+ layer: "api",
576
+ files: [
577
+ { name: `route.ts`, purpose: "목록/생성 API (GET→ListQuery, POST→CreateCommand)", template: "route", isSlot: true },
578
+ { name: `[id]/route.ts`, purpose: "상세/수정/삭제 API (GET→GetQuery, PUT→UpdateCommand, DELETE→DeleteCommand)", template: "route", isSlot: true },
579
+ ],
580
+ },
581
+ ],
582
+
583
+ api: (name) => [
584
+ {
585
+ path: `src/domain/${name}`,
586
+ purpose: `${name} 도메인`,
587
+ layer: "domain",
588
+ files: [
589
+ { name: `${name}.service.ts`, purpose: "도메인 서비스", template: "service" },
590
+ { name: `${name}.types.ts`, purpose: "타입 정의", template: "type" },
591
+ ],
592
+ },
593
+ {
594
+ path: `src/application/commands/${name}`,
595
+ purpose: `${name} 커맨드`,
596
+ layer: "application/commands",
597
+ files: [
598
+ { name: `${name}.command.ts`, purpose: "커맨드 핸들러", template: "command" },
599
+ ],
600
+ },
601
+ {
602
+ path: `src/application/queries/${name}`,
603
+ purpose: `${name} 쿼리`,
604
+ layer: "application/queries",
605
+ files: [
606
+ { name: `${name}.query.ts`, purpose: "쿼리 핸들러", template: "query" },
607
+ ],
608
+ },
609
+ {
610
+ path: `src/api/${name}`,
611
+ purpose: `${name} API 엔드포인트`,
612
+ layer: "api",
613
+ files: [
614
+ { name: `route.ts`, purpose: "API 핸들러 → Command/Query 디스패치", template: "route", isSlot: true },
615
+ ],
616
+ },
617
+ ],
618
+
619
+ ui: (name) => [
620
+ {
621
+ path: `src/application/queries/${name}`,
622
+ purpose: `${name} 데이터 조회`,
623
+ layer: "application/queries",
624
+ files: [
625
+ { name: `get-${name}.query.ts`, purpose: "UI용 데이터 조회 쿼리", template: "query" },
626
+ ],
627
+ },
628
+ {
629
+ path: `src/application/dto/${name}`,
630
+ purpose: `${name} DTO`,
631
+ layer: "application/dto",
632
+ files: [
633
+ { name: `${name}-view.dto.ts`, purpose: "뷰 모델 DTO", template: "dto" },
634
+ ],
635
+ },
636
+ {
637
+ path: `src/api/${name}`,
638
+ purpose: `${name} API`,
639
+ layer: "api",
640
+ files: [
641
+ { name: `route.ts`, purpose: "UI 데이터 API", template: "route", isSlot: true },
642
+ ],
643
+ },
644
+ ],
645
+
646
+ integration: (name) => [
647
+ {
648
+ path: `src/domain/${name}`,
649
+ purpose: `${name} 도메인 포트`,
650
+ layer: "domain",
651
+ files: [
652
+ { name: `${name}.port.ts`, purpose: "포트 인터페이스", template: "type" },
653
+ ],
654
+ },
655
+ {
656
+ path: `src/application/commands/${name}`,
657
+ purpose: `${name} 동기화 커맨드`,
658
+ layer: "application/commands",
659
+ files: [
660
+ { name: `sync-${name}.command.ts`, purpose: "외부 서비스 동기화 커맨드", template: "command" },
661
+ ],
662
+ },
663
+ {
664
+ path: `src/application/events/${name}`,
665
+ purpose: `${name} 연동 이벤트`,
666
+ layer: "application/events",
667
+ files: [
668
+ { name: `${name}-synced.event.ts`, purpose: "동기화 완료 이벤트", template: "event" },
669
+ ],
670
+ },
671
+ {
672
+ path: `src/infra/${name}`,
673
+ purpose: `${name} 외부 서비스 어댑터`,
674
+ layer: "infrastructure",
675
+ files: [
676
+ { name: `${name}.client.ts`, purpose: "외부 API 클라이언트", template: "service" },
677
+ { name: `${name}.config.ts`, purpose: "연동 설정", template: "util" },
678
+ ],
679
+ },
680
+ {
681
+ path: `src/api/webhooks/${name}`,
682
+ purpose: `${name} 웹훅`,
683
+ layer: "api",
684
+ files: [
685
+ { name: `route.ts`, purpose: "웹훅 핸들러", template: "route", isSlot: true },
686
+ ],
687
+ },
688
+ ],
689
+
690
+ data: (name) => [
691
+ {
692
+ path: `src/domain/${name}`,
693
+ purpose: `${name} 데이터 처리 도메인`,
694
+ layer: "domain",
695
+ files: [
696
+ { name: `${name}.types.ts`, purpose: "타입 정의", template: "type" },
697
+ ],
698
+ },
699
+ {
700
+ path: `src/application/commands/${name}`,
701
+ purpose: `${name} 데이터 처리 커맨드`,
702
+ layer: "application/commands",
703
+ files: [
704
+ { name: `import-${name}.command.ts`, purpose: "데이터 임포트 커맨드", template: "command" },
705
+ { name: `transform-${name}.command.ts`, purpose: "데이터 변환 커맨드", template: "command" },
706
+ ],
707
+ },
708
+ {
709
+ path: `src/application/queries/${name}`,
710
+ purpose: `${name} 데이터 조회`,
711
+ layer: "application/queries",
712
+ files: [
713
+ { name: `export-${name}.query.ts`, purpose: "데이터 익스포트 쿼리", template: "query" },
714
+ ],
715
+ },
716
+ {
717
+ path: `src/application/dto/${name}`,
718
+ purpose: `${name} DTO`,
719
+ layer: "application/dto",
720
+ files: [
721
+ { name: `${name}-import.dto.ts`, purpose: "임포트 DTO", template: "dto" },
722
+ ],
723
+ },
724
+ ],
725
+
726
+ util: (name) => [
727
+ {
728
+ path: `src/shared/${name}`,
729
+ purpose: `${name} 유틸리티`,
730
+ layer: "shared",
731
+ files: [
732
+ { name: `${name}.ts`, purpose: "유틸리티 함수", template: "util" },
733
+ { name: `${name}.test.ts`, purpose: "테스트", template: "test" },
734
+ { name: `index.ts`, purpose: "Public API", template: "util" },
735
+ ],
736
+ },
737
+ ],
738
+
739
+ config: (name) => [
740
+ {
741
+ path: `src/shared/config`,
742
+ purpose: "설정 관리",
743
+ layer: "shared",
744
+ files: [
745
+ { name: `${name}.config.ts`, purpose: `${name} 설정`, template: "util" },
746
+ { name: `${name}.schema.ts`, purpose: "설정 스키마 (Zod)", template: "type" },
747
+ ],
748
+ },
749
+ ],
750
+
751
+ other: (name) => [
752
+ {
753
+ path: `src/domain/${name}`,
754
+ purpose: `${name} 도메인`,
755
+ layer: "domain",
756
+ files: [
757
+ { name: `${name}.service.ts`, purpose: "도메인 서비스", template: "service" },
758
+ { name: `${name}.types.ts`, purpose: "타입 정의", template: "type" },
759
+ ],
760
+ },
761
+ {
762
+ path: `src/application/commands/${name}`,
763
+ purpose: `${name} 커맨드`,
764
+ layer: "application/commands",
765
+ files: [
766
+ { name: `${name}.command.ts`, purpose: "커맨드 핸들러", template: "command" },
767
+ ],
768
+ },
769
+ {
770
+ path: `src/application/queries/${name}`,
771
+ purpose: `${name} 쿼리`,
772
+ layer: "application/queries",
773
+ files: [
774
+ { name: `${name}.query.ts`, purpose: "쿼리 핸들러", template: "query" },
775
+ ],
776
+ },
777
+ ],
778
+ };
779
+
441
780
  // ═══════════════════════════════════════════════════════════════════════════
442
781
  // File Templates
443
782
  // ═══════════════════════════════════════════════════════════════════════════
@@ -557,6 +896,91 @@ describe("${name}", () => {
557
896
  expect(true).toBe(true);
558
897
  });
559
898
  });
899
+ `;
900
+
901
+ case "command":
902
+ return `/**
903
+ * ${purpose}
904
+ *
905
+ * Command Handler - 쓰기 경로
906
+ */
907
+
908
+ export interface ${toPascalCase(name)}Command {
909
+ // TODO: Define command payload
910
+ }
911
+
912
+ export interface ${toPascalCase(name)}Result {
913
+ // TODO: Define command result
914
+ }
915
+
916
+ export class ${toPascalCase(name)}Handler {
917
+ async execute(command: ${toPascalCase(name)}Command): Promise<${toPascalCase(name)}Result> {
918
+ // TODO: Implement command handler
919
+ throw new Error("Not implemented");
920
+ }
921
+ }
922
+ `;
923
+
924
+ case "query":
925
+ return `/**
926
+ * ${purpose}
927
+ *
928
+ * Query Handler - 읽기 경로
929
+ */
930
+
931
+ export interface ${toPascalCase(name)}Query {
932
+ // TODO: Define query parameters
933
+ }
934
+
935
+ export interface ${toPascalCase(name)}Result {
936
+ // TODO: Define query result
937
+ }
938
+
939
+ export class ${toPascalCase(name)}Handler {
940
+ async execute(query: ${toPascalCase(name)}Query): Promise<${toPascalCase(name)}Result> {
941
+ // TODO: Implement query handler
942
+ throw new Error("Not implemented");
943
+ }
944
+ }
945
+ `;
946
+
947
+ case "event":
948
+ return `/**
949
+ * ${purpose}
950
+ *
951
+ * Domain Event
952
+ */
953
+
954
+ export interface ${toPascalCase(name)}Event {
955
+ readonly type: "${name}";
956
+ readonly occurredAt: Date;
957
+ // TODO: Define event payload
958
+ }
959
+
960
+ export function create${toPascalCase(name)}Event(
961
+ // TODO: Define factory parameters
962
+ ): ${toPascalCase(name)}Event {
963
+ return {
964
+ type: "${name}",
965
+ occurredAt: new Date(),
966
+ };
967
+ }
968
+ `;
969
+
970
+ case "dto":
971
+ return `/**
972
+ * ${purpose}
973
+ *
974
+ * Data Transfer Object
975
+ */
976
+
977
+ export interface ${toPascalCase(name)}Dto {
978
+ // TODO: Define DTO fields
979
+ }
980
+
981
+ export interface ${toPascalCase(name)}ResponseDto {
982
+ // TODO: Define response DTO fields
983
+ }
560
984
  `;
561
985
 
562
986
  case "util":
@@ -659,6 +1083,7 @@ function adjustStructureForPreset(
659
1083
  "shared": "src/utils",
660
1084
  "app/api": "src/api",
661
1085
  },
1086
+ cqrs: {}, // CQRS 전용 템플릿이 자체 경로 사용
662
1087
  mandu: {}, // 기본값, 매핑 불필요
663
1088
  };
664
1089
 
@@ -716,11 +1141,12 @@ export async function negotiate(
716
1141
 
717
1142
  // 4. 프리셋 정의 로드 및 구조 템플릿 선택
718
1143
  const presetDef = getPreset(preset);
719
- const templateFn = STRUCTURE_TEMPLATES[category] || STRUCTURE_TEMPLATES.other;
1144
+ const templates = preset === "cqrs" ? CQRS_STRUCTURE_TEMPLATES : STRUCTURE_TEMPLATES;
1145
+ const templateFn = templates[category] || templates.other;
720
1146
  let structure = templateFn(featureName);
721
1147
 
722
- // 5. 프리셋에 따른 구조 조정
723
- if (presetDef && preset !== "mandu") {
1148
+ // 5. 프리셋에 따른 구조 조정 (cqrs, mandu는 자체 경로 사용)
1149
+ if (presetDef && preset !== "mandu" && preset !== "cqrs") {
724
1150
  structure = adjustStructureForPreset(structure, presetDef, preset);
725
1151
  }
726
1152
 
@@ -0,0 +1,154 @@
1
+ /**
2
+ * CQRS Preset Tests
3
+ *
4
+ * CQRS 프리셋의 구조 검증 및 Command/Query 분리 규칙 테스트
5
+ */
6
+
7
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
8
+ import { mkdtemp, rm, mkdir } from "fs/promises";
9
+ import { join } from "path";
10
+ import { tmpdir } from "os";
11
+ import { cqrsPreset, CQRS_HIERARCHY } from "./cqrs";
12
+ import { presets, getPreset } from "./index";
13
+ import { negotiate, generateScaffold } from "../negotiation";
14
+
15
+ // ═══════════════════════════════════════════════════════════════════════════
16
+ // Preset Structure Tests
17
+ // ═══════════════════════════════════════════════════════════════════════════
18
+
19
+ describe("CQRS Preset - Structure", () => {
20
+ it("should be registered in presets map", () => {
21
+ expect(presets.cqrs).toBeDefined();
22
+ expect(presets.cqrs).toBe(cqrsPreset);
23
+ });
24
+
25
+ it("should be retrievable via getPreset", () => {
26
+ expect(getPreset("cqrs")).toBe(cqrsPreset);
27
+ });
28
+
29
+ it("should have correct name and description", () => {
30
+ expect(cqrsPreset.name).toBe("cqrs");
31
+ expect(cqrsPreset.description).toContain("CQRS");
32
+ });
33
+
34
+ it("should define all 10 layers", () => {
35
+ expect(cqrsPreset.layers).toHaveLength(10);
36
+ });
37
+
38
+ it("should have hierarchy matching layer names", () => {
39
+ const layerNames = cqrsPreset.layers.map((l) => l.name);
40
+ for (const h of CQRS_HIERARCHY) {
41
+ expect(layerNames).toContain(h);
42
+ }
43
+ });
44
+
45
+ it("should set layerViolation severity to error", () => {
46
+ expect(cqrsPreset.defaultSeverity?.layerViolation).toBe("error");
47
+ });
48
+ });
49
+
50
+ // ═══════════════════════════════════════════════════════════════════════════
51
+ // CQRS Separation Rules - TODO(human)
52
+ // ═══════════════════════════════════════════════════════════════════════════
53
+
54
+ describe("CQRS Preset - Command/Query Separation", () => {
55
+ // TODO(human): CQRS 분리 규칙 검증 테스트를 작성하세요.
56
+ //
57
+ // 힌트:
58
+ // - cqrsPreset.layers 배열에서 특정 레이어를 찾으려면:
59
+ // const commandsLayer = cqrsPreset.layers.find(l => l.name === "application/commands");
60
+ // - commandsLayer.canImport 배열에 특정 레이어가 포함/미포함인지 검증
61
+ //
62
+ // 작성해야 할 테스트 케이스:
63
+ // 1. commands는 queries를 import할 수 없어야 함
64
+ // 2. queries는 commands와 events를 import할 수 없어야 함
65
+ // 3. commands는 domain, dto, events를 import할 수 있어야 함
66
+ // 4. queries는 domain, dto를 import할 수 있어야 함
67
+ // 5. domain은 shared만 import할 수 있어야 함
68
+ // 6. shared는 아무것도 import할 수 없어야 함
69
+ });
70
+
71
+ // ═══════════════════════════════════════════════════════════════════════════
72
+ // CQRS Scaffolding Tests
73
+ // ═══════════════════════════════════════════════════════════════════════════
74
+
75
+ let SCAFFOLD_DIR: string;
76
+
77
+ beforeAll(async () => {
78
+ SCAFFOLD_DIR = await mkdtemp(join(tmpdir(), "test-cqrs-scaffold-"));
79
+ await mkdir(join(SCAFFOLD_DIR, "spec", "decisions"), { recursive: true });
80
+ });
81
+
82
+ afterAll(async () => {
83
+ await rm(SCAFFOLD_DIR, { recursive: true, force: true });
84
+ });
85
+
86
+ describe("CQRS Preset - Scaffold Templates", () => {
87
+ it("should generate commands/queries separation for auth", async () => {
88
+ const result = await negotiate(
89
+ { intent: "사용자 인증 추가", category: "auth", preset: "cqrs" },
90
+ SCAFFOLD_DIR,
91
+ );
92
+
93
+ expect(result.approved).toBe(true);
94
+ expect(result.preset).toBe("cqrs");
95
+
96
+ const paths = result.structure.map((s) => s.path);
97
+ expect(paths.some((p) => p.includes("application/commands"))).toBe(true);
98
+ expect(paths.some((p) => p.includes("application/queries"))).toBe(true);
99
+ expect(paths.some((p) => p.includes("application/dto"))).toBe(true);
100
+ expect(paths.some((p) => p.includes("application/events"))).toBe(true);
101
+ });
102
+
103
+ it("should generate CRUD with separate commands and queries", async () => {
104
+ const result = await negotiate(
105
+ { intent: "상품 관리 CRUD", category: "crud", preset: "cqrs" },
106
+ SCAFFOLD_DIR,
107
+ );
108
+
109
+ const commandsDir = result.structure.find((s) => s.path.includes("application/commands"));
110
+ const queriesDir = result.structure.find((s) => s.path.includes("application/queries"));
111
+
112
+ expect(commandsDir).toBeDefined();
113
+ expect(queriesDir).toBeDefined();
114
+
115
+ // commands에 create/update/delete가 있어야 함
116
+ const cmdFiles = commandsDir!.files.map((f) => f.name);
117
+ expect(cmdFiles.some((f) => f.includes("create"))).toBe(true);
118
+ expect(cmdFiles.some((f) => f.includes("update"))).toBe(true);
119
+ expect(cmdFiles.some((f) => f.includes("delete"))).toBe(true);
120
+
121
+ // queries에 get/list가 있어야 함
122
+ const qryFiles = queriesDir!.files.map((f) => f.name);
123
+ expect(qryFiles.some((f) => f.includes("get"))).toBe(true);
124
+ expect(qryFiles.some((f) => f.includes("list"))).toBe(true);
125
+ });
126
+
127
+ it("should create actual scaffold files", async () => {
128
+ const testDir = await mkdtemp(join(SCAFFOLD_DIR, "actual-"));
129
+ await mkdir(join(testDir, "spec", "decisions"), { recursive: true });
130
+
131
+ const plan = await negotiate(
132
+ { intent: "order feature", category: "crud", preset: "cqrs" },
133
+ testDir,
134
+ );
135
+ const result = await generateScaffold(plan.structure, testDir);
136
+
137
+ expect(result.success).toBe(true);
138
+ expect(result.createdFiles.some((f) => f.includes("command"))).toBe(true);
139
+ expect(result.createdFiles.some((f) => f.includes("query"))).toBe(true);
140
+ });
141
+
142
+ it("should use src/ prefixed paths (no adjustStructureForPreset)", async () => {
143
+ const result = await negotiate(
144
+ { intent: "payment api", category: "api", preset: "cqrs" },
145
+ SCAFFOLD_DIR,
146
+ );
147
+
148
+ // CQRS 전용 템플릿은 이미 src/ 기준 경로를 사용
149
+ const paths = result.structure.map((s) => s.path);
150
+ for (const p of paths) {
151
+ expect(p.startsWith("src/")).toBe(true);
152
+ }
153
+ });
154
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * CQRS Preset
3
+ *
4
+ * Command Query Responsibility Segregation 아키텍처
5
+ * 쓰기 경로(Commands)와 읽기 경로(Queries)의 격리를 프레임워크 레벨에서 강제
6
+ */
7
+
8
+ import type { PresetDefinition } from "../types";
9
+
10
+ /**
11
+ * CQRS 레이어 계층 구조
12
+ *
13
+ * 의존성: 외부 → 내부 (domain이 핵심)
14
+ * commands와 queries는 서로 격리됨
15
+ */
16
+ export const CQRS_HIERARCHY = [
17
+ "api",
18
+ "infra",
19
+ "application/commands",
20
+ "application/queries",
21
+ "application/dto",
22
+ "application/mappers",
23
+ "application/events",
24
+ "domain",
25
+ "core",
26
+ "shared",
27
+ ] as const;
28
+
29
+ /**
30
+ * CQRS 프리셋 정의
31
+ */
32
+ export const cqrsPreset: PresetDefinition = {
33
+ name: "cqrs",
34
+ description: "CQRS - Command/Query 분리 아키텍처",
35
+
36
+ hierarchy: [...CQRS_HIERARCHY],
37
+
38
+ layers: [
39
+ {
40
+ name: "api",
41
+ pattern: "src/**/api/**",
42
+ canImport: ["application/commands", "application/queries", "application/dto", "core", "shared"],
43
+ description: "Controllers, Routes - Command/Query 디스패치",
44
+ },
45
+ {
46
+ name: "infra",
47
+ pattern: "src/**/infra/**",
48
+ canImport: ["application/commands", "application/queries", "domain", "core", "shared"],
49
+ description: "Repository 구현, 외부 서비스 어댑터",
50
+ },
51
+ {
52
+ name: "application/commands",
53
+ pattern: "src/**/application/commands/**",
54
+ canImport: ["domain", "application/dto", "application/events", "core", "shared"],
55
+ description: "Command Handlers - 쓰기 경로 (queries 접근 불가)",
56
+ },
57
+ {
58
+ name: "application/queries",
59
+ pattern: "src/**/application/queries/**",
60
+ canImport: ["domain", "application/dto", "core", "shared"],
61
+ description: "Query Handlers - 읽기 경로 (commands, events 접근 불가)",
62
+ },
63
+ {
64
+ name: "application/dto",
65
+ pattern: "src/**/application/dto/**",
66
+ canImport: ["domain", "shared"],
67
+ description: "Data Transfer Objects",
68
+ },
69
+ {
70
+ name: "application/mappers",
71
+ pattern: "src/**/application/mappers/**",
72
+ canImport: ["domain", "application/dto", "shared"],
73
+ description: "Domain ↔ DTO 변환기",
74
+ },
75
+ {
76
+ name: "application/events",
77
+ pattern: "src/**/application/events/**",
78
+ canImport: ["domain", "shared"],
79
+ description: "Domain Events, Integration Events",
80
+ },
81
+ {
82
+ name: "domain",
83
+ pattern: "src/**/domain/**",
84
+ canImport: ["shared"],
85
+ description: "Entities, Value Objects, Domain Services, Aggregates",
86
+ },
87
+ {
88
+ name: "core",
89
+ pattern: "src/core/**",
90
+ canImport: ["shared"],
91
+ description: "공통 핵심 (auth, config, errors, CQRS bus)",
92
+ },
93
+ {
94
+ name: "shared",
95
+ pattern: "src/shared/**",
96
+ canImport: [],
97
+ description: "공유 유틸리티, 타입, 인터페이스",
98
+ },
99
+ ],
100
+
101
+ defaultSeverity: {
102
+ layerViolation: "error",
103
+ circularDependency: "error",
104
+ crossSliceDependency: "warn",
105
+ deepNesting: "info",
106
+ },
107
+ };
@@ -9,12 +9,14 @@ import { fsdPreset, FSD_HIERARCHY } from "./fsd";
9
9
  import { cleanPreset, CLEAN_HIERARCHY } from "./clean";
10
10
  import { hexagonalPreset, HEXAGONAL_HIERARCHY } from "./hexagonal";
11
11
  import { atomicPreset, ATOMIC_HIERARCHY } from "./atomic";
12
+ import { cqrsPreset, CQRS_HIERARCHY } from "./cqrs";
12
13
 
13
14
  // Re-export
14
15
  export { fsdPreset, FSD_HIERARCHY } from "./fsd";
15
16
  export { cleanPreset, CLEAN_HIERARCHY } from "./clean";
16
17
  export { hexagonalPreset, HEXAGONAL_HIERARCHY } from "./hexagonal";
17
18
  export { atomicPreset, ATOMIC_HIERARCHY } from "./atomic";
19
+ export { cqrsPreset, CQRS_HIERARCHY } from "./cqrs";
18
20
 
19
21
  /**
20
22
  * Mandu 권장 프리셋 (FSD + Clean 조합)
@@ -263,6 +265,7 @@ export const presets: Record<GuardPreset, PresetDefinition> = {
263
265
  clean: cleanPreset,
264
266
  hexagonal: hexagonalPreset,
265
267
  atomic: atomicPreset,
268
+ cqrs: cqrsPreset,
266
269
  mandu: manduPreset,
267
270
  };
268
271
 
@@ -35,6 +35,12 @@ const DOCS: Record<GuardPreset | "default", Record<string, string>> = {
35
35
  molecules: "https://bradfrost.com/blog/post/atomic-web-design/#molecules",
36
36
  organisms: "https://bradfrost.com/blog/post/atomic-web-design/#organisms",
37
37
  },
38
+ cqrs: {
39
+ base: "https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs",
40
+ commands: "https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs#solution",
41
+ queries: "https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs#solution",
42
+ layers: "https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html",
43
+ },
38
44
  mandu: {
39
45
  base: "https://github.com/mandujs/mandu/docs/guard",
40
46
  layers: "https://github.com/mandujs/mandu/docs/guard#layers",
@@ -20,6 +20,7 @@ export type GuardPreset =
20
20
  | "clean" // Clean Architecture
21
21
  | "hexagonal" // Hexagonal Architecture
22
22
  | "atomic" // Atomic Design
23
+ | "cqrs" // CQRS (Command Query Responsibility Segregation)
23
24
  | "mandu"; // Mandu 권장 (FSD + Clean 조합)
24
25
 
25
26
  /**