From d3aeff6865e9172fa6701686449750ae64af91a1 Mon Sep 17 00:00:00 2001 From: ifLov Date: Thu, 3 Sep 2026 14:05:27 +0900 Subject: [PATCH 1/3] =?UTF-8?q?docs(architecture):=20inbound/outbound=20po?= =?UTF-8?q?rt=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B3=A0=20=EC=A0=84=EC=97=AD?= =?UTF-8?q?=20=EB=B2=84=EC=8A=A4=EB=A5=BC=20=EA=B8=B0=EB=B3=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=BA=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파생 프로젝트(ceseem-backend CES-125)에서 이 문서의 규칙이 실제 코드와 부딪혀 값을 못 한다는 것이 드러났다. 교차 검증 2건(Claude Fable 5.1 · Codex gpt-5.6-sol)과 후속 조사가 각자 같은 결론에 왔고, 그 결과를 정본으로 되돌린다. ## 바뀐 것 1. capability 구조에 ports/in · ports/out. 방향이 곧 공개 여부다 — in은 밖에서 나를 부르는 계약이라 공개, out은 내가 DB를 부르는 계약이라 비공개. adapters/in·adapters/out과 어휘가 통일된다. 2. 「CQRS-lite」를 「Inbound port」로 바꿨다. 전역 Command/Query 버스는 더 이상 기본이 아니다. 버스가 사려던 것 셋 중 하나도 실현되지 않았다: - "소비자가 상대 module을 import하지 않는다" — 네 module이 그대로 import하고 있었다 - "나중에 프로세스를 나눌 수 있다" — handler 절반이 MANDATORY라 호출자의 트랜잭션과 행 잠금을 공유한다. 나누면 에러 없이 깨진다 - "결합이 준다" — 아는 대상이 클래스에서 메시지 클래스로 바뀌었을 뿐 강도가 같다 대신 handler 17개 중 8개가 port 메서드를 그대로 전달했고, 같은 규칙을 다른 capability에 적용하면 9/18, 5/12가 같은 모양이 됐다. 원인은 "다른 capability의 데이터에 메시지로만 닿는다"는 규칙이고, cross-capability 읽기·잠금은 모듈러 모놀리스에서 정상이다. 버스를 쓰는 조건을 명시했다 — 여러 inbound가 같은 메시지를 보내거나 dispatch 자체가 값을 하는 경우. 3. 「Mandatory outbound ports」를 「Outbound port」로. 의존 역전이 실제로 값을 하는 자리가 여기 하나뿐임을 적었다. IRepository를 새 capability의 기본으로 삼지 말라는 경고와 그 근거(FOR UPDATE, ON CONFLICT RETURNING, raw SET, 범위 비교가 표현되지 않는다)를 넣었다. 4. domain/이 cross-capability 공개 표면이라는 판정. 조건도 함께 적었다 — 타입과 순수 함수만. 5. cross-capability ORM relation은 클래스가 아니라 엔티티 이름 상수로 가리킨다. FK는 relation decorator에서만 유도되므로 컬럼만 남기면 migration이 FK를 DROP한다(실측). 6. ESLint 강제 목록을 넷으로 명시하고 4번(capability를 넘는 adapter import 차단)을 추가했다. 규칙 2번은 import하는 파일이 application/·domain/일 때만 걸려서 scripts/·test/와 전환하지 않은 코드가 그대로 새고 있었다. ⚠ base no-restricted-imports를 쓴 것이 의도다. 기존 셋이 전부 @typescript-eslint/no-restricted-imports이고 files가 겹치므로 같은 이름으로 블록을 더하면 flat config가 그 셋을 조용히 교체한다. 추가 후 위반 코드를 실제로 넣어 새 규칙이 물고 기존 spread 금지가 여전히 무는 것을 확인했다. 7. 머리말에 "파생 프로젝트의 실측이 이 문서를 고친다"를 넣었다. 템플릿이 뒤처지면 새 프로젝트가 이미 폐기된 형태로 시작한다. ## 이 커밋이 코드를 고치지 않는다 문서를 먼저 고치고 코드를 뒤에 옮긴다. 지금 어긋난 자리를 「이 템플릿이 아직 문서를 따라가지 못하는 곳」 표에 적어뒀다 — src/health가 QueryBus로 진입하는 것, health.controller가 execute()에 타입 인자를 둘 명시해 결과 타입 거짓말이 tsc를 통과하는 것, ports/가 in/out으로 나뉘지 않은 것, architecture-rule-fixture의 빈 디렉토리 셋. 게이트: lint / typecheck 통과, 35 files 317 tests 전부 pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012SpbEMwFkuau6vEGhJMG7o --- ARCHITECTURE.md | 176 +++++++++++++++++++++++++++++++++++++--------- eslint.config.mjs | 46 ++++++++++++ 2 files changed, 187 insertions(+), 35 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 486f3da..3ac7617 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,6 +4,11 @@ 추가하거나 기존 실행 경로, 외부 I/O, module communication, persistence model을 변경할 때 먼저 이 규칙을 적용한다. +**파생 프로젝트의 실측이 이 문서를 고친다.** 규칙이 실제 코드와 부딪혀 값을 못 하는 것이 +드러나면 그 근거와 함께 여기를 바꾼다 — 아래 「Inbound port」와 「Outbound port」가 +2026-09-03에 그렇게 바뀌었다(ceseem-backend CES-125). 템플릿이 파생 프로젝트보다 뒤처지면 +새 프로젝트가 이미 폐기된 형태로 시작한다. + ## 고정 문법 Barebones는 **feature-first hexagonal modular monolith**다. 제품과 운영 capability는 자신이 쓰는 @@ -18,7 +23,7 @@ HTTP / CLI / message ↓ inbound adapter ↓ -CommandHandler | QueryHandler +inbound port 구현 (application service) ↓ application coordinator (여러 entry point가 판단을 공유할 때) ↓ @@ -30,10 +35,11 @@ DB / remote API / queue / cache / filesystem compile-time -inbound adapter ──→ application/domain -application ──→ outbound port -outbound adapter ──→ outbound port + application/domain types -composition root ──→ application + adapters +inbound adapter ──→ inbound port +다른 capability ──→ inbound port (+ 상대의 domain type·순수 함수) +application ──→ outbound port +outbound adapter ──→ outbound port + application/domain types +composition root ──→ 두 port에 구현을 묶는다 ``` Capability는 `src//`에 둔다. 기존 `src/health/`가 운영 capability reference다. @@ -41,36 +47,73 @@ Capability는 `src//`에 둔다. 기존 `src/health/`가 운영 capa ```text src// adapters/ - in/http/ - out/persistence/ + in/http/ controller, dto, guard, pipe + out/persistence/ entity, repository (port 구현) application/ - commands/ - queries/ ports/ - domain/ # domain type이나 business rule이 있을 때 - .module.ts # composition root + in/ 이 capability가 밖에 제공하는 interface + Symbol token ← 공개 + out/ 이 capability가 밖에 요구하는 interface + Symbol token ← 비공개 + .service.ts inbound port의 구현. module exports에 실리지 않는다 + domain/ # domain type이나 business rule이 있을 때 ← 공개 + .module.ts # composition root — 두 port에 구현을 묶는 유일한 지점 ``` -빈 폴더는 만들지 않는다. 구조의 고정점은 폴더 수가 아니라 capability 소유권, CQRS 진입점, -mandatory outbound port와 의존 방향이다. +**방향이 곧 공개 여부다.** `in`은 밖에서 나를 부르는 계약이라 공개하고, `out`은 내가 DB를 +부르는 계약이라 감춘다. `adapters/in`·`adapters/out`과 어휘가 하나로 통일되고, 외울 규칙이 +아니라 방향에서 따라 나온다. + +빈 폴더는 만들지 않는다. 구조의 고정점은 폴더 수가 아니라 capability 소유권, 두 방향의 port, +의존 방향이다. -## CQRS-lite +## Inbound port — 밖에서 나를 부르는 계약 -- create/update/delete와 외부 side effect를 시작하는 write는 `Command`와 `CommandHandler`가 맡는다. -- read는 `Query`와 `QueryHandler`가 맡는다. -- Handler가 application use case다. 같은 판단을 전달만 하는 별도 `UseCase` 클래스를 겹쳐 만들지 않는다. -- HTTP와 CLI처럼 여러 entry point가 같은 판단을 쓰거나 여러 handler가 같은 orchestration을 쓸 때만 - application coordinator를 추출한다. +- capability가 밖에 제공하는 것은 `application/ports/in/`의 **interface + Symbol token** 하나다. + 소비자(HTTP adapter든 다른 capability든)는 `@Inject(TOKEN)`으로 받고 타입은 `import type`으로 본다. +- 구현 클래스는 module `exports`에 싣지 않는다. 그래야 소비자의 런타임 import가 Symbol 하나로 + 줄고 **capability 사이 순환 참조가 생기지 않는다.** +- 메서드 목록이 곧 *"이 capability가 무엇을 할 수 있는가"*다. 한 파일에서 함께 읽힌다. +- 판단이 없는 위임 메서드가 섞이는 것은 정상이다. adapter가 비공개이므로 밖에서 읽으려면 이 + 계약을 지나야 하고, 그 비용은 **한 줄**이다. +- 같은 판단을 여러 entry point가 쓰거나 여러 흐름이 같은 orchestration을 쓸 때만 application + coordinator를 추출한다. - 별도 read database, event sourcing, eventual consistency는 요구사항이 있을 때 도입한다. +### 전역 Command/Query 버스는 기본이 아니다 + +이 문서는 2026-09-03까지 *"CQRS-lite — write는 `Command`+`CommandHandler`, read는 +`Query`+`QueryHandler`"*를 기본으로 규정했다. 파생 프로젝트에서 **그 규칙이 값을 못 한다는 +것이 실측으로 드러나** 바꿨다 (ceseem-backend CES-125, 교차 검증 2건 + 후속 조사). + +버스가 사려던 것 셋 중 하나도 실현되지 않았다: + +- _"소비자가 상대 module을 import하지 않는다"_ — 실제로는 네 module이 그대로 import하고 있었다 +- _"나중에 프로세스를 나눌 수 있다"_ — handler 절반이 `Propagation.MANDATORY`라 호출자의 + 트랜잭션과 행 잠금을 공유한다. 프로세스를 나누면 **에러 없이** 깨진다 +- _"결합이 준다"_ — 아는 대상이 클래스에서 메시지 클래스로 바뀌었을 뿐 강도가 같다 + +대신 한 capability의 handler 17개 중 **8개가 port 메서드를 그대로 전달**했고, 판단이 있는 +9개와 똑같이 생겨서 어느 쪽인지 열어봐야 했다. 같은 규칙을 다른 capability에 적용하면 +9/18, 5/12가 같은 모양이 됐다. 원인은 *"다른 capability의 데이터에 메시지로만 닿는다"*는 +규칙이고, cross-capability 읽기·잠금은 모듈러 모놀리스에서 정상이다. + +interface를 두는 값은 **의존 역전이 아니다** — 부르는 쪽이 나를 아는 것은 원래 옳은 방향이다. +값은 셋이다: 공개 표면을 좁히는 것, 런타임 import를 Symbol 하나로 줄여 **순환 참조를 막는 것**, +테스트 대역 자리를 만드는 것. 버스는 메시지 클래스를 런타임 import하므로 그중 둘째조차 못 준다. + +**버스를 쓰는 조건**: 같은 메시지를 **여러 inbound가 보내거나**, dispatch 자체가 값을 하는 +경우(공통 pipeline, 비동기 전달, 독립 read model)다. 그 조건이 없으면 쓰지 않는다. + 기술 표현만 노출하는 endpoint는 예외다. `/metrics`처럼 library-native representation을 그대로 반환하고 application 판단, authorization, ownership, orchestration이 없는 endpoint는 inbound -infrastructure adapter 안에서 끝날 수 있다. 이 조건 중 하나라도 생기면 Command/Query 경로로 올린다. +infrastructure adapter 안에서 끝날 수 있다. -## Mandatory outbound ports +## Outbound port — 내가 밖에 요구하는 계약 -Application이 외부 I/O를 필요로 하면 구현체가 하나여도 port를 반드시 둔다. Port는 application이 -외부 세계에 요구하는 capability 언어이며 adapter가 구현한다. +Application이 외부 I/O를 필요로 하면 구현체가 하나여도 port를 반드시 둔다. +`application/ports/out/`에 두고 adapter가 구현한다. + +**여기가 의존 역전이 실제로 값을 하는 자리다** — application이 ORM을 모르므로 DB 없이 테스트하고, +저장소를 바꿔도 판단이 안 바뀐다. inbound 쪽에는 뒤집을 것이 없다는 것이 두 방향의 차이다. ```ts export interface UserRepositoryPort { @@ -98,9 +141,20 @@ Port는 사용하는 capability가 소유한다. `UserRepositoryPort`, `PaymentG 여러 capability가 delivery semantics까지 의도적으로 공유하는 `MessageQueuePort` 같은 platform interface만 `src/common/`에 둘 수 있다. -`src/common/persistence/IRepository`는 TypeORM adapter를 구현하기 위한 내부 기반이다. Feature -adapter는 필요하면 이 기반을 조합할 수 있지만 CommandHandler, QueryHandler, coordinator의 -interface에는 `FindCriteria`, `WhereFilter`, `Partial` 기반 CRUD를 노출하지 않는다. +⚠ **범용 `IRepository` 기반을 새 capability의 기본으로 삼지 않는다.** 파생 프로젝트에서 두 +표본이 같은 결론을 냈다 (2026-09-03, ceseem-backend). adapter가 실제로 필요로 한 것은 +pessimistic lock(`FOR UPDATE`), `ON CONFLICT DO NOTHING ... RETURNING`, `"col" + 1` 같은 raw +SET, 범위 비교였고 그 넷 다 generic 기반으로 표현되지 않아 우회했다. `WhereFilter`가 범위·부분 +일치·OR을 **의도적으로** 뺐으므로 표현하려면 포트를 ORM 쿼리 언어로 넓혀야 하는데, 그것은 이 +문서가 금지하는 바로 그것이다. 실사용 소비자가 끝까지 0이었다. + +Adapter는 raw ORM을 쓰고, 경계는 **"ORM을 감추는 곳"이 아니라 "ORM이 밖으로 안 나가는 곳"**이다. +application interface에 `FindCriteria`, `WhereFilter`, `Partial` 기반 CRUD를 노출하지 않는 +규칙은 그대로다. + +반복되는 mapper나 driver error translation이 **실제로 세 번째** 생기면 그때 +`adapters/out/persistence/` 전용의 작은 helper로 뽑는다. application의 범용 CRUD port로 +다시 올리지 않는다. ## Domain and persistence models @@ -126,14 +180,36 @@ interface에는 `FindCriteria`, `WhereFilter`, `Partial` 기반 CRUD를 노 ## Module communication -- 다른 capability가 즉시 결과를 필요로 하면 상대 capability의 이름 있는 application interface를 +- 다른 capability가 즉시 결과를 필요로 하면 상대의 `application/ports/in/` interface를 주입받아 동기 호출한다. Adapter 내부나 ORM repository를 직접 import하지 않는다. +- **상대의 `domain/`은 공개 표면이다.** 거기 있는 domain type과 순수 함수는 다른 capability가 + 직접 import해도 된다. `isVerified(user)`처럼 세 줄로 답하는 순수 함수를 호출로 감싸는 것은 + 비용도 읽기도 손해이고, `src/common/`으로 올리면 그것이 도메인 지식의 하치장이 된다. + + 대가는 분명히 해둔다: `domain/`에 **판단이 새면 그 결합도 조용히 따라온다.** 그래서 + `domain/`에는 타입과 순수 함수만 둔다. 상태를 읽거나 외부와 말하는 순간 그것은 + `application/`이고, 밖에서는 inbound port로만 닿는다. + +- 그래서 한 capability가 밖에 허용하는 것은 **① `application/ports/in/`의 interface + Symbol + token**과 **② `domain/`의 타입·순수 함수** 둘이다. `adapters/`와 `application/ports/out/`은 + 아니다. - 독립 후속 작업과 temporal decoupling이 필요하면 event/message를 사용하고 delivery, retry, idempotency, DLQ semantics를 adapter 구성에 명시한다. - 하나의 invariant를 원자적으로 지켜야 할 때만 shared transaction을 검토한다. - Cross-module ORM relation decorator와 DB foreign key는 별개 결정이다. Relation navigation을 편의상 추가하지 않고 owner와 coupled change surface를 먼저 확인한다. + ⚠ **FK는 relation decorator에서만 유도된다.** 컬럼만 남기고 relation을 지우면 다음 + `migration:generate`가 그 FK를 **DROP하는 SQL을 뱉는다**(실측). 그래서 다른 capability의 + entity를 가리켜야 할 때는 클래스 대신 **엔티티 이름 상수**를 쓴다 — 소유 capability가 + `domain/`에 `const X_ENTITY_NAME = 'X'`를 공개하고, 가리키는 쪽은 + `@ManyToOne(X_ENTITY_NAME, ...)`에 `prop?: unknown`을 쓴다. 스키마와 런타임 동작은 클래스를 + 넘길 때와 같고 컴파일 시점 의존만 사라진다. + + 이름이 어긋나면 `tsc`가 아니라 DataSource 초기화가 죽으므로 + (`Entity metadata for X#prop was not found`), 소유 capability의 entity spec이 클래스 이름과 + 상수를 대조한다 — **이름을 바꾸는 사람 옆에서** 먼저 깨지게 한다. + ## Composition and platform modules `src/infra/`와 `src/config/`는 제품 capability가 아니라 기술 선택과 application composition을 @@ -145,22 +221,52 @@ concrete provider wiring은 module/config에 남긴다. ## Tests and enforcement -- Handler/coordinator test는 port fake를 주입하고 observable result와 side effect를 검증한다. +- application service test는 **outbound port fake**를 주입하고 observable result와 side effect를 + 검증한다. +- 소비자 test는 상대의 **inbound port fake**를 주입한다. 대역이 메서드 이름을 갖는 것이 값이다. - Adapter test는 실제 protocol/driver translation과 error mapping을 검증한다. - RDB semantics는 선택한 실제 Docker database로 E2E 검증한다. - HTTP E2E는 status, headers, validation, response representation을 검증한다. - Test는 production interface를 사용한다. TypeORM repository나 adapter 내부를 직접 호출해 business behavior를 우회하지 않는다. -ESLint는 `application/`과 `domain/`에서 adapter/infra 역방향 import 및 ORM, DB driver, Redis, -BullMQ, Mongoose, Express, filesystem/network package import를 차단한다. 정적 import 검사만으로 -mandatory port의 의미나 structural type 누출까지 증명할 수 없으므로 이름 있는 port와 test surface는 -review에서 함께 확인한다. +ESLint가 강제하는 것: + +1. ORM·DB driver·Redis·queue·Express·filesystem/network package를 `application/`·`domain/`에서 차단 +2. `application/`·`domain/` → `adapters/`·`infra/` 역방향 import 차단 +3. `application/ports/`·`domain/`에서 `@nestjs/*` 차단 +4. **capability를 넘는 `adapters/**`import를`src/**`·`scripts/**`·`test/**` 전체에서 차단** + +⚠ 4번이 따로 필요한 이유 (2026-09-03 실측): 2번은 **import하는 파일이** `application/`·`domain/`일 +때만 걸린다. 그래서 아직 전환하지 않은 코드와 `scripts/`·`test/`는 아무 제약이 없고, 실제로 +소비자 spec 3개와 스크립트 1개가 게이트를 전부 통과한 채 남의 adapter를 잡고 있었다. +module의 `exports`가 막는 것은 **생성자 주입뿐**이다 — `app.get()`, type import, spec은 못 막는다. + +⚠ 4번을 추가할 때 **같은 룰 이름으로 블록을 새로 만들지 않는다.** ESLint flat config는 같은 룰을 +병합하지 않고 **교체**하므로, `files`가 겹치는 기존 블록의 규칙이 조용히 사라진다. 이름이 다른 +base `no-restricted-imports`를 쓰거나 기존 블록 안에 넣는다. 추가한 뒤 위반 코드를 실제로 넣어 +**기존 규칙이 여전히 무는지** 확인한다. + +정적 import 검사만으로 port의 의미나 structural type 누출까지 증명할 수 없으므로 이름 있는 port와 +test surface는 review에서 함께 확인한다. ## Current references -- Health: `src/health/` — HTTP Query, coordinator, outbound port, 여러 adapter, readiness mapping. +- Health: `src/health/` — HTTP 진입점, coordinator, outbound port, 여러 adapter, readiness mapping. - Messaging: `src/common/messaging/message-queue.port.ts`와 `src/infra/queue/` — shared platform port와 BullMQ adapter/delivery policy. 제품 job handler reference는 아직 아니다. -- Persistence: `src/common/persistence/`와 `test/persistence.e2e-spec.ts` — adapter 내부 generic 기반과 - 실제 DB translation test. 제품 repository port reference는 첫 product capability에서 추가한다. +- Persistence: `src/common/persistence/`와 `test/persistence.e2e-spec.ts` — 실제 DB translation test가 + 붙어 있는 generic 기반. **새 capability의 기본으로 쓰지 않는다**(위 「Outbound port」의 ⚠ 참고). + +## 이 템플릿이 아직 문서를 따라가지 못하는 곳 + +문서를 먼저 고치고 코드를 뒤에 옮긴다. 지금 어긋난 자리를 적어둔다 — 적어두지 않으면 다음 +프로젝트가 이 코드를 보고 이미 폐기된 형태로 시작한다. + +| 자리 | 어긋난 것 | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/health/` | `QueryBus` + `GetHealthQuery`로 진입한다. 호출자가 `health.controller.ts` 하나뿐이라 위 「전역 Command/Query 버스는 기본이 아니다」의 조건을 만족하지 못한다. `application/ports/in/health.port.ts` + `HealthCoordinator` 주입으로 옮긴다 | +| `health.controller.ts` | `execute(...)`로 타입 인자를 **둘** 명시한다. `@nestjs/cqrs`의 두 번째 오버로드가 잡혀 결과 타입을 거짓말해도 `tsc`가 통과시킨다(파생 프로젝트에서 실측). 버스를 걷어내면 이 함정 자체가 사라진다 | +| `application/ports/` | `in`/`out`으로 나뉘어 있지 않다 | +| `src/architecture-rule-fixture/` | 파일 없는 빈 디렉토리 셋. *"빈 폴더는 만들지 않는다"*를 템플릿이 어기고 있다 | +| ESLint | 위 「Tests and enforcement」의 4번(capability를 넘는 adapter import 차단)이 없다 | diff --git a/eslint.config.mjs b/eslint.config.mjs index f034dd6..208270b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -173,6 +173,52 @@ export default tseslint.config( ], }, }, + { + // ARCHITECTURE.md — 다른 capability의 adapter를 직접 import하지 않는다. + // + // ## 왜 규칙이 하나 더 필요한가 (2026-09-03, 파생 프로젝트에서 실측) + // + // 아래 역방향 차단은 **import하는 파일이** `application/`·`domain/`일 때만 걸린다. + // 그래서 아직 전환하지 않은 capability의 service·spec과 `scripts/`·`test/`는 아무 제약이 + // 없고, 실제로 소비자 spec 3개와 스크립트 1개가 게이트를 전부 통과한 채 남의 adapter를 + // 잡고 있었다. module의 `exports`가 막는 것은 **생성자 주입뿐**이다 — + // `app.get()`, type import, spec은 못 막는다. + // + // ## ⚠ 규칙 이름을 base `no-restricted-imports`로 쓴 이유 + // + // ESLint flat config는 같은 룰을 **병합하지 않고 교체한다.** 아래 세 블록이 전부 + // `@typescript-eslint/no-restricted-imports`이고 `files`가 겹치므로, 같은 이름으로 + // 블록을 하나 더 만들면 **그 셋이 조용히 사라진다.** 이름이 다른 base 룰을 쓰면 + // 교체가 일어나지 않는다. (위 A-5 spread 블록에 적힌 것과 같은 함정이다.) + // + // ## 무엇을 막고 무엇을 허용하나 + // + // 막는 것은 **capability 이름을 거쳐 adapters로 들어가는 경로**다. 자기 capability 안의 + // `./adapters/**`·`../adapters/**`는 걸리지 않는다 — module이 자기 adapter를 조립하는 + // 것은 composition root의 일이다. + files: ['src/**/*.ts', 'scripts/**/*.ts', 'test/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '../*/adapters/**', + '../../*/adapters/**', + '../../../*/adapters/**', + '../../../../*/adapters/**', + '../src/*/adapters/**', + '../../src/*/adapters/**', + ], + message: + 'ARCHITECTURE.md: 다른 capability의 adapter를 직접 import하지 않는다. 그쪽이 공개한 application/ports/in 계약이나 domain 타입을 쓸 것. 테스트도 production interface를 쓴다.', + }, + ], + }, + ], + }, + }, { // ARCHITECTURE.md — application/domain은 port 안쪽이며 concrete adapter를 선택하지 않는다. files: ['src/**/application/**/*.ts', 'src/**/domain/**/*.ts'], From 1da4296af431e8b5381471adb2a89e6b0200192a Mon Sep 17 00:00:00 2001 From: ifLov Date: Thu, 3 Sep 2026 14:45:14 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor(health):=20=EC=A0=84=EC=97=AD=20Qu?= =?UTF-8?q?eryBus=EB=A5=BC=20=EA=B1=B7=EC=96=B4=EB=82=B4=EA=B3=A0=20inboun?= =?UTF-8?q?d=20port=EB=A1=9C=20=EC=A7=84=EC=9E=85=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECTURE.md의 「이 템플릿이 아직 문서를 따라가지 못하는 곳」표 다섯 줄을 닫는다. 문서는 d3aeff6에서 inbound/outbound port로 바뀌었는데 코드가 따라가지 않았고, 이 저장소는 파생 프로젝트가 보고 시작하는 유일한 참조 구현이라 어긋난 채 두면 다음 프로젝트가 이미 폐기된 형태로 시작한다. 지운 것 - `application/queries/` — `GetHealthQuery`, `GetHealthQueryHandler`와 그 spec. handler는 `coordinator.check()`를 그대로 전달했고, 보내는 곳이 `health.controller.ts` 하나였다. ARCHITECTURE.md가 정한 버스 사용 조건(같은 메시지를 여러 inbound가 보내거나 dispatch 자체가 값을 하는 경우)을 만족하지 못한다. - `@nestjs/cqrs` 의존성과 `AppModule`의 `CqrsModule.forRoot()` — 사용처가 0이 됐다. 함께 사라지는 것이 `execute(...)` 함정이다. 타입 인자를 둘 명시하면 두 번째 오버로드가 잡혀 결과 타입을 거짓말해도 tsc가 통과시켰다(파생 프로젝트 실측). 메서드 시그니처에는 그 문제가 없다 — 결과 타입이 port에 한 번 적히고 호출부는 추론만 받는다. - `src/architecture-rule-fixture/`의 빈 디렉토리 셋. git이 추적하지 않던 것이라 체크아웃에서만 지웠다(새 clone에는 원래 없다). 이 커밋에 파일 변경은 없다. 옮긴 것 - `application/ports/in/health.port.ts` — `HealthPort` + `HEALTH` Symbol. `check()`와 `inspectIndicators()` 둘 다 담는다. entry point가 HTTP와 Prometheus scrape 둘이고, 둘 다 이 계약을 지난다. - `application/ports/health-indicator.port.ts` → `ports/out/`. 방향이 곧 공개 여부이므로 `in`은 공개, `out`은 비공개다. - `domain/system-health.ts` — `HealthStatus`, `HealthIndicatorSnapshot`, `SystemHealth`. 두 방향의 port가 모두 쓰는 타입이라 domain에 둔다. 비공개인 `ports/out/`에 두면 소비자가 결과 타입을 이름 부르려고 비공개 파일을 열어야 한다. - `HealthCoordinator`가 `HealthPort`를 구현하고, module은 `exports: [HEALTH, ...]`로 토큰만 내보낸다. controller와 `HealthMetricsService`는 `@Inject(HEALTH)`로 받는다. 구현 클래스가 소비자의 런타임 import 그래프에 들어가지 않는다. - `health.controller.spec.ts` 추가 — 소비자 test가 inbound port fake를 주입하는 형태의 표본. 대역이 `check`라는 메서드 이름을 갖는 것이 값이다. ESLint 규칙 4번(capability를 넘는 adapter import 차단)은 d3aeff6에 이미 들어와 있었다. 표의 그 줄만 stale이었다. 위반 파일을 실제로 만들어 `src/`·`scripts/`· `test/` 세 곳에서 무는 것과, flat config 교체 함정에도 불구하고 기존 규칙(A-5 객체 spread, port의 @nestjs 차단, application → adapters 역방향)이 여전히 무는 것을 확인했다. 문서 - ARCHITECTURE.md의 표를 비우고 무엇을 어떻게 닫았는지 남긴다. `Current references`의 health 줄을 두 방향 port 기준으로 고친다. - README.md·AGENTS.md·CLAUDE.md의 "CQRS-lite" 절이 여전히 Command/Query handler를 기본 규칙으로 규정하고 있었다. 에이전트와 파생 프로젝트가 읽는 것이 이 셋이라 코드만 바꾸면 다음 프로젝트가 여전히 폐기된 형태를 지시받는다. port 경계 서술로 바꾼다. `docs/`의 과거 조사 기록은 그 시점의 관측이므로 그대로 둔다. 검증: lint, typecheck, test(35 files / 318), check:scaffold, check:observability, build, test:e2e(2 files / 20, Docker postgres) 모두 통과. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BpFLpds7xSWRhwheHcyZYf --- AGENTS.md | 18 ++++---- ARCHITECTURE.md | 22 +++++----- CLAUDE.md | 16 +++---- README.md | 25 ++++++----- package.json | 1 - pnpm-lock.yaml | 22 ---------- src/app.module.ts | 2 - .../in/http/health.controller.spec.ts | 32 ++++++++++++++ .../adapters/in/http/health.controller.ts | 13 +++--- .../adapters/out/disk-health.adapter.ts | 6 +-- .../adapters/out/memory-health.adapter.ts | 6 +-- .../adapters/out/mongodb-health.adapter.ts | 6 +-- src/health/adapters/out/rdb-health.adapter.ts | 6 +-- .../adapters/out/redis-health.adapter.ts | 6 +-- .../application/health.coordinator.spec.ts | 2 +- src/health/application/health.coordinator.ts | 19 +++++---- .../ports/health-indicator.port.ts | 19 --------- .../application/ports/in/health.port.ts | 42 +++++++++++++++++++ .../ports/out/health-indicator.port.ts | 14 +++++++ .../queries/get-health.query-handler.spec.ts | 12 ------ .../queries/get-health.query-handler.ts | 15 ------- .../application/queries/get-health.query.ts | 2 - src/health/domain/system-health.ts | 21 ++++++++++ src/health/health-metrics.service.ts | 10 ++--- src/health/health.module.ts | 20 +++++---- 25 files changed, 200 insertions(+), 157 deletions(-) create mode 100644 src/health/adapters/in/http/health.controller.spec.ts delete mode 100644 src/health/application/ports/health-indicator.port.ts create mode 100644 src/health/application/ports/in/health.port.ts create mode 100644 src/health/application/ports/out/health-indicator.port.ts delete mode 100644 src/health/application/queries/get-health.query-handler.spec.ts delete mode 100644 src/health/application/queries/get-health.query-handler.ts delete mode 100644 src/health/application/queries/get-health.query.ts create mode 100644 src/health/domain/system-health.ts diff --git a/AGENTS.md b/AGENTS.md index c782bf4..59afbcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,9 @@ ```text adapters/in - → application/commands | application/queries - → application service/coordinator - → application port + → application/ports/in (interface + Symbol 토큰) + → application service/coordinator (inbound port의 구현) + → application/ports/out → adapters/out → external system ``` @@ -20,16 +20,20 @@ adapters/in - 의존성은 바깥 adapter에서 application/domain 방향으로 향한다. - application/domain은 Nest transport, ORM, DB driver, Redis, BullMQ 타입을 import하지 않는다. - application이 외부 I/O를 사용하면 구현체 수와 관계없이 capability가 소유한 이름 있는 port를 둔다. -- Handler가 use case다. 같은 판단을 전달만 하는 별도 UseCase 계층을 겹쳐 만들지 않는다. +- Application service가 use case다. 같은 판단을 전달만 하는 별도 UseCase 계층을 겹쳐 만들지 않는다. - ORM model은 outbound adapter가 소유하고 변환은 adapter 안의 작은 함수에서 시작한다. - Controller는 HTTP 입력·출력·상태 코드만 책임진다. - CLI와 HTTP가 같은 판단을 사용하면 Controller가 아니라 application coordinator를 재사용한다. - health Controller는 coordinator 결과 `up/down`을 각각 `200/503`으로 매핑한다. -## CQRS-lite +## Port 경계 -- create/update/delete는 Command와 CommandHandler를 사용한다. -- read는 Query와 QueryHandler를 사용한다. +- capability가 밖에 공개하는 것은 `application/ports/in/`의 interface + Symbol 토큰과 `domain/`의 + 타입·순수 함수 둘뿐이다. `adapters/`와 `application/ports/out/`은 공개하지 않는다. +- 구현 클래스는 module `exports`에 싣지 않는다. 소비자는 `@Inject(TOKEN)`으로 받고 타입은 + `import type`으로 본다 — 그래야 capability 사이 순환 참조가 생기지 않는다. +- 전역 `CommandBus`/`QueryBus`는 기본이 아니다. 같은 메시지를 여러 inbound가 보내거나 dispatch + 자체가 값을 할 때만 쓴다(`ARCHITECTURE.md`의 「전역 Command/Query 버스는 기본이 아니다」). - 이름 있는 write/query port를 사용하고 범용 CRUD 계약을 application interface에 노출하지 않는다. - 별도 read database, event sourcing, eventual consistency는 요구사항이 있을 때만 도입한다. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3ac7617..9f7b5ad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -252,7 +252,9 @@ test surface는 review에서 함께 확인한다. ## Current references -- Health: `src/health/` — HTTP 진입점, coordinator, outbound port, 여러 adapter, readiness mapping. +- Health: `src/health/` — inbound port(`application/ports/in/health.port.ts`)와 그 구현인 + coordinator, outbound `HealthIndicatorPort`와 여러 adapter, `domain/`의 공개 타입, + HTTP readiness mapping. 두 방향의 port가 모두 있는 유일한 capability다. - Messaging: `src/common/messaging/message-queue.port.ts`와 `src/infra/queue/` — shared platform port와 BullMQ adapter/delivery policy. 제품 job handler reference는 아직 아니다. - Persistence: `src/common/persistence/`와 `test/persistence.e2e-spec.ts` — 실제 DB translation test가 @@ -260,13 +262,13 @@ test surface는 review에서 함께 확인한다. ## 이 템플릿이 아직 문서를 따라가지 못하는 곳 -문서를 먼저 고치고 코드를 뒤에 옮긴다. 지금 어긋난 자리를 적어둔다 — 적어두지 않으면 다음 -프로젝트가 이 코드를 보고 이미 폐기된 형태로 시작한다. +문서를 먼저 고치고 코드를 뒤에 옮긴다. 지금 어긋난 자리를 적어둔다. -| 자리 | 어긋난 것 | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/health/` | `QueryBus` + `GetHealthQuery`로 진입한다. 호출자가 `health.controller.ts` 하나뿐이라 위 「전역 Command/Query 버스는 기본이 아니다」의 조건을 만족하지 못한다. `application/ports/in/health.port.ts` + `HealthCoordinator` 주입으로 옮긴다 | -| `health.controller.ts` | `execute(...)`로 타입 인자를 **둘** 명시한다. `@nestjs/cqrs`의 두 번째 오버로드가 잡혀 결과 타입을 거짓말해도 `tsc`가 통과시킨다(파생 프로젝트에서 실측). 버스를 걷어내면 이 함정 자체가 사라진다 | -| `application/ports/` | `in`/`out`으로 나뉘어 있지 않다 | -| `src/architecture-rule-fixture/` | 파일 없는 빈 디렉토리 셋. *"빈 폴더는 만들지 않는다"*를 템플릿이 어기고 있다 | -| ESLint | 위 「Tests and enforcement」의 4번(capability를 넘는 adapter import 차단)이 없다 | +**현재 비어 있다.** 2026-09-03에 다섯 줄을 모두 닫았다 — `src/health/`가 `QueryBus` + +`GetHealthQuery` 대신 `application/ports/in/health.port.ts`로 진입하고, `ports/`가 `in`/`out`으로 +나뉘었으며, `@nestjs/cqrs`가 의존성에서 빠졌고, 「Tests and enforcement」의 4번 lint 규칙이 +들어왔다. `src/architecture-rule-fixture/`의 빈 디렉토리 셋은 git이 추적하지 않던 것이라 +체크아웃에서만 지웠다 — 새 clone에는 원래 없다. + +다음에 어긋나는 것이 생기면 여기에 표로 적는다. 적어두지 않으면 다음 프로젝트가 이 코드를 +보고 이미 폐기된 형태로 시작한다. diff --git a/CLAUDE.md b/CLAUDE.md index cc30458..6280dd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,13 +26,13 @@ pnpm build [`ARCHITECTURE.md`](./ARCHITECTURE.md)를 먼저 읽는다. 이 절은 항상 필요한 실행 요약만 둔다. ```text -adapter/in → application command/query → application port → adapter/out +adapter/in → application/ports/in → application service → application/ports/out → adapter/out ``` - Controller는 HTTP 상태와 DTO 변환만 담당한다. -- CommandHandler는 상태 변경 사용 사례를 실행한다. -- QueryHandler는 조회 사용 사례를 실행한다. -- Handler가 use case다. 전달만 하는 별도 UseCase 계층을 겹쳐 만들지 않는다. +- capability가 밖에 공개하는 것은 `application/ports/in/`의 interface + Symbol 토큰과 `domain/`의 + 타입·순수 함수 둘뿐이다. 구현 클래스는 module `exports`에 싣지 않는다. +- Application service가 use case다. 전달만 하는 별도 UseCase 계층을 겹쳐 만들지 않는다. - HTTP, CLI, metrics가 같은 판단을 해야 하면 application coordinator를 공유한다. - application/domain에서 TypeORM, Prisma, Mongoose, Redis, BullMQ 타입을 import하지 않는다. - application이 외부 I/O를 사용하면 구현체 수와 관계없이 capability가 소유한 이름 있는 port를 둔다. @@ -107,10 +107,12 @@ optionalDependencies로 싣는다. (`ReferenceError: exports is not defined in ES module scope`). - **TypeScript 7 차단을 풀지 못한다.** 빌더와 무관하게 Nest CLI가 compiler API를 먼저 요구한다. -## CQRS-lite +## Port 경계 -- create/update/delete: Command + CommandHandler + 이름 있는 write port -- read: Query + QueryHandler; 외부 I/O가 있으면 이름 있는 query port +- 소비자는 상대의 inbound port를 `@Inject(TOKEN)`으로 받고 타입은 `import type`으로 본다. +- 외부 I/O는 구현체가 하나여도 `application/ports/out/`에 이름 있는 port를 둔다. +- 전역 `CommandBus`/`QueryBus`는 기본이 아니다. 같은 메시지를 여러 inbound가 보내거나 dispatch + 자체가 값을 할 때만 쓴다(`ARCHITECTURE.md`의 「전역 Command/Query 버스는 기본이 아니다」). - 범용 CRUD repository를 도메인 API로 노출하지 않는다. - event sourcing, 별도 read database, eventual consistency는 제품 요구가 있을 때만 추가한다. diff --git a/README.md b/README.md index 6a03dcf..b89d0cf 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Barebones NestJS Scaffold 도메인과 인증을 포함하지 않는 NestJS 12 백엔드 시작점입니다. 파생 프로젝트가 필요한 도메인만 -추가할 수 있도록 헥사고날 경계, CQRS-lite, 로컬 Docker 인프라와 운영 기본값을 제공합니다. +추가할 수 있도록 헥사고날 경계, 두 방향의 port, 로컬 Docker 인프라와 운영 기본값을 제공합니다. ## 포함 범위 -- HTTP inbound adapter → CQRS command/query handler → application port → outbound adapter +- HTTP inbound adapter → inbound port → application service → outbound port → outbound adapter - 생성 시 한 번 선택하는 RDB/ORM 조합 - RDB: PostgreSQL, MySQL, MariaDB - ORM: TypeORM(기본), Prisma, MikroORM, Drizzle 프로필 @@ -78,19 +78,24 @@ pnpm scaffold:select --orm=drizzle --database=mariadb --apply ```text HTTP / CLI - → Controller / Command - → CommandHandler | QueryHandler - → Application service or coordinator - → Port + → Controller + → inbound port (application/ports/in) + → application service (inbound port의 구현) + → outbound port (application/ports/out) → TypeORM | Prisma | MikroORM | Drizzle | MongoDB | Redis | BullMQ/SQS/... ``` -CQRS-lite 규칙: +경계 규칙: -- create/update/delete는 CommandHandler와 쓰기 포트를 사용합니다. -- read는 QueryHandler를 사용하고 외부 I/O가 있으면 이름 있는 QueryPort를 사용합니다. +- capability가 밖에 공개하는 것은 `application/ports/in/`의 interface + Symbol 토큰과 `domain/`의 + 타입·순수 함수 둘뿐입니다. `adapters/`와 `application/ports/out/`은 공개하지 않습니다. +- 소비자는 `@Inject(TOKEN)`으로 받고 타입은 `import type`으로 봅니다. 구현 클래스는 module + `exports`에 싣지 않습니다 — 그래야 capability 사이 순환 참조가 생기지 않습니다. +- 전역 `CommandBus`/`QueryBus`는 기본이 아닙니다. 같은 메시지를 여러 inbound가 보내거나 dispatch + 자체가 값을 할 때만 씁니다. 근거는 [ARCHITECTURE.md](./ARCHITECTURE.md)의 + 「전역 Command/Query 버스는 기본이 아니다」에 있습니다. - 외부 I/O port는 구현체가 하나여도 사용하는 capability가 소유합니다. -- Handler가 use case이므로 전달만 하는 별도 UseCase 계층을 겹치지 않습니다. +- Application service가 use case이므로 전달만 하는 별도 UseCase 계층을 겹치지 않습니다. - ORM model은 outbound adapter가 소유하며 별도 Mapper 클래스는 mapping이 복잡해질 때만 만듭니다. - 단순 기능에 별도 read model이나 event sourcing을 강제하지 않습니다. - 한 aggregate의 authoritative store는 하나입니다. RDB와 MongoDB 동시 쓰기는 기본값이 아닙니다. diff --git a/package.json b/package.json index c9d20dd..7b00d88 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "@nestjs/common": "^12.0.1", "@nestjs/config": "^12.0.0", "@nestjs/core": "^12.0.1", - "@nestjs/cqrs": "^12.0.0", "@nestjs/mongoose": "^12.0.0", "@nestjs/platform-express": "^12.0.1", "@nestjs/swagger": "^12.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0651e00..26607c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,9 +25,6 @@ importers: '@nestjs/core': specifier: ^12.0.1 version: 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/cqrs': - specifier: ^12.0.0 - version: 12.0.0(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/mongoose': specifier: ^12.0.0 version: 12.0.0(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.0.1)(mongoose@9.9.2)(rxjs@7.8.2) @@ -1254,18 +1251,6 @@ packages: '@nestjs/websockets': optional: true - '@nestjs/cqrs@12.0.0': - resolution: - { - integrity: sha512-X97NjPIkChMZ9Pfb3JJSLhKtDKCnzk44/Qx2pxoWk7OFMAS3Pl/WtDW0DdDt5eH1IwGNuaG7kQI42OIRdWlkcQ==, - } - engines: { node: ^20.19.0 || >=22.12.0 } - peerDependencies: - '@nestjs/common': ^12.0.0 - '@nestjs/core': ^12.0.0 - reflect-metadata: ^0.1.13 || ^0.2.0 - rxjs: ^7.2.0 - '@nestjs/mapped-types@12.0.0': resolution: { @@ -6708,13 +6693,6 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.0.1)(supports-color@8.1.1) - '@nestjs/cqrs@12.0.0(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 12.0.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - '@nestjs/mapped-types@12.0.0(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 12.0.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) diff --git a/src/app.module.ts b/src/app.module.ts index ad4cbe4..1f2c4d8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,7 +2,6 @@ import { CacheModule } from '@nestjs/cache-manager'; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; -import { CqrsModule } from '@nestjs/cqrs'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { LoggerModule } from 'nestjs-pino'; @@ -44,7 +43,6 @@ const { }, }, }), - CqrsModule.forRoot(), LoggerModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/src/health/adapters/in/http/health.controller.spec.ts b/src/health/adapters/in/http/health.controller.spec.ts new file mode 100644 index 0000000..99ac7c8 --- /dev/null +++ b/src/health/adapters/in/http/health.controller.spec.ts @@ -0,0 +1,32 @@ +import { ServiceUnavailableException } from '@nestjs/common'; + +import type { HealthPort } from '../../../application/ports/in/health.port.js'; +import type { SystemHealth } from '../../../domain/system-health.js'; +import { HealthController } from './health.controller.js'; + +/** + * 소비자 test는 상대의 **inbound port fake**를 주입한다(ARCHITECTURE.md 「Tests and enforcement」). + * 대역이 `check`라는 메서드 이름을 갖는 것이 값이다 — 버스 시절에는 `execute()` 하나였다. + */ +function port(result: SystemHealth): HealthPort { + return { + check: vi.fn().mockResolvedValue(result), + inspectIndicators: vi.fn(), + }; +} + +describe('HealthController', () => { + it('returns the system health as-is when it is up', async () => { + const health: SystemHealth = { indicators: { database: { status: 'up' } }, status: 'up' }; + + await expect(new HealthController(port(health)).check()).resolves.toBe(health); + }); + + it('maps a down system to 503', async () => { + const health: SystemHealth = { indicators: { redis: { status: 'down' } }, status: 'down' }; + + await expect(new HealthController(port(health)).check()).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + }); +}); diff --git a/src/health/adapters/in/http/health.controller.ts b/src/health/adapters/in/http/health.controller.ts index a326b2d..e8a28fe 100644 --- a/src/health/adapters/in/http/health.controller.ts +++ b/src/health/adapters/in/http/health.controller.ts @@ -1,17 +1,16 @@ -import { Controller, Get, ServiceUnavailableException, Version } from '@nestjs/common'; -import { QueryBus } from '@nestjs/cqrs'; +import { Controller, Get, Inject, ServiceUnavailableException, Version } from '@nestjs/common'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { SkipThrottle } from '@nestjs/throttler'; import { HEALTH_ROUTE_PATH } from '../../../../config/observability.config.js'; -import type { SystemHealth } from '../../../application/ports/health-indicator.port.js'; -import { GetHealthQuery } from '../../../application/queries/get-health.query.js'; +import { HEALTH, type HealthPort } from '../../../application/ports/in/health.port.js'; +import type { SystemHealth } from '../../../domain/system-health.js'; -/** HTTP inbound adapter. 헬스 판단은 application 계층에 위임하고 HTTP 상태만 변환한다. */ +/** HTTP inbound adapter. 헬스 판단은 inbound port에 위임하고 HTTP 상태만 변환한다. */ @ApiTags('health') @Controller(HEALTH_ROUTE_PATH) export class HealthController { - constructor(private readonly queryBus: QueryBus) {} + constructor(@Inject(HEALTH) private readonly health: HealthPort) {} @Get() @Version('1') @@ -19,7 +18,7 @@ export class HealthController { @ApiOperation({ summary: 'Application health check' }) @ApiOkResponse({ description: 'Returns application health information' }) async check(): Promise { - const result = await this.queryBus.execute(new GetHealthQuery()); + const result = await this.health.check(); if (result.status === 'down') { throw new ServiceUnavailableException('Health check failed'); diff --git a/src/health/adapters/out/disk-health.adapter.ts b/src/health/adapters/out/disk-health.adapter.ts index 1d8bd2d..e762e92 100644 --- a/src/health/adapters/out/disk-health.adapter.ts +++ b/src/health/adapters/out/disk-health.adapter.ts @@ -2,10 +2,8 @@ import { statfs } from 'node:fs/promises'; import { Inject, Injectable } from '@nestjs/common'; -import type { - HealthIndicatorPort, - HealthIndicatorSnapshot, -} from '../../application/ports/health-indicator.port.js'; +import type { HealthIndicatorPort } from '../../application/ports/out/health-indicator.port.js'; +import type { HealthIndicatorSnapshot } from '../../domain/system-health.js'; export interface DiskSpaceSnapshot { readonly bavail: number; diff --git a/src/health/adapters/out/memory-health.adapter.ts b/src/health/adapters/out/memory-health.adapter.ts index 73a94c5..98980a5 100644 --- a/src/health/adapters/out/memory-health.adapter.ts +++ b/src/health/adapters/out/memory-health.adapter.ts @@ -1,10 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import type { - HealthIndicatorPort, - HealthIndicatorSnapshot, -} from '../../application/ports/health-indicator.port.js'; +import type { HealthIndicatorPort } from '../../application/ports/out/health-indicator.port.js'; +import type { HealthIndicatorSnapshot } from '../../domain/system-health.js'; export interface MemoryUsageProbe { heapUsed(): number; diff --git a/src/health/adapters/out/mongodb-health.adapter.ts b/src/health/adapters/out/mongodb-health.adapter.ts index bb53358..5f58a5d 100644 --- a/src/health/adapters/out/mongodb-health.adapter.ts +++ b/src/health/adapters/out/mongodb-health.adapter.ts @@ -2,10 +2,8 @@ import { Injectable } from '@nestjs/common'; import { InjectConnection } from '@nestjs/mongoose'; import mongoose, { type Connection } from 'mongoose'; -import type { - HealthIndicatorPort, - HealthIndicatorSnapshot, -} from '../../application/ports/health-indicator.port.js'; +import type { HealthIndicatorPort } from '../../application/ports/out/health-indicator.port.js'; +import type { HealthIndicatorSnapshot } from '../../domain/system-health.js'; @Injectable() export class MongodbHealthAdapter implements HealthIndicatorPort { diff --git a/src/health/adapters/out/rdb-health.adapter.ts b/src/health/adapters/out/rdb-health.adapter.ts index 2ca5d06..e163fa9 100644 --- a/src/health/adapters/out/rdb-health.adapter.ts +++ b/src/health/adapters/out/rdb-health.adapter.ts @@ -4,10 +4,8 @@ import { RDB_HEALTH_PROBE, type RdbHealthProbePort, } from '../../../common/persistence/rdb-health-probe.port.js'; -import type { - HealthIndicatorPort, - HealthIndicatorSnapshot, -} from '../../application/ports/health-indicator.port.js'; +import type { HealthIndicatorPort } from '../../application/ports/out/health-indicator.port.js'; +import type { HealthIndicatorSnapshot } from '../../domain/system-health.js'; @Injectable() export class RdbHealthAdapter implements HealthIndicatorPort { diff --git a/src/health/adapters/out/redis-health.adapter.ts b/src/health/adapters/out/redis-health.adapter.ts index 38cf431..79be524 100644 --- a/src/health/adapters/out/redis-health.adapter.ts +++ b/src/health/adapters/out/redis-health.adapter.ts @@ -1,10 +1,8 @@ import { Injectable } from '@nestjs/common'; import { RedisService } from '../../../infra/redis/redis.service.js'; -import type { - HealthIndicatorPort, - HealthIndicatorSnapshot, -} from '../../application/ports/health-indicator.port.js'; +import type { HealthIndicatorPort } from '../../application/ports/out/health-indicator.port.js'; +import type { HealthIndicatorSnapshot } from '../../domain/system-health.js'; @Injectable() export class RedisHealthAdapter implements HealthIndicatorPort { diff --git a/src/health/application/health.coordinator.spec.ts b/src/health/application/health.coordinator.spec.ts index 73c4cf8..904d538 100644 --- a/src/health/application/health.coordinator.spec.ts +++ b/src/health/application/health.coordinator.spec.ts @@ -1,5 +1,5 @@ import { HealthCoordinator } from './health.coordinator.js'; -import type { HealthIndicatorPort } from './ports/health-indicator.port.js'; +import type { HealthIndicatorPort } from './ports/out/health-indicator.port.js'; function indicator(key: string, result: { status: 'down' | 'up' } | Error): HealthIndicatorPort { return { diff --git a/src/health/application/health.coordinator.ts b/src/health/application/health.coordinator.ts index 037336b..359dd5e 100644 --- a/src/health/application/health.coordinator.ts +++ b/src/health/application/health.coordinator.ts @@ -1,15 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; -import { - HEALTH_INDICATORS, - type HealthIndicatorPort, - type HealthIndicatorSnapshot, - type SystemHealth, -} from './ports/health-indicator.port.js'; - -/** HTTP, CLI, Prometheus가 공통으로 사용하는 health 조율자. */ +import type { HealthIndicatorSnapshot, SystemHealth } from '../domain/system-health.js'; +import type { HealthPort } from './ports/in/health.port.js'; +import { HEALTH_INDICATORS, type HealthIndicatorPort } from './ports/out/health-indicator.port.js'; + +/** + * inbound port `HealthPort`의 구현. HTTP와 Prometheus가 같은 판정을 공유하므로 coordinator다. + * + * module `exports`에 실리지 않는다 — 밖으로 나가는 것은 `HEALTH` 토큰 하나다. + */ @Injectable() -export class HealthCoordinator { +export class HealthCoordinator implements HealthPort { constructor( @Inject(HEALTH_INDICATORS) private readonly indicators: readonly HealthIndicatorPort[], ) {} diff --git a/src/health/application/ports/health-indicator.port.ts b/src/health/application/ports/health-indicator.port.ts deleted file mode 100644 index d5cbbc8..0000000 --- a/src/health/application/ports/health-indicator.port.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type HealthStatus = 'down' | 'up'; - -export interface HealthIndicatorSnapshot { - readonly message?: string; - readonly status: HealthStatus; -} - -/** 개별 의존성 상태를 조회하는 outbound port. */ -export interface HealthIndicatorPort { - readonly key: string; - check(): Promise; -} - -export interface SystemHealth { - readonly indicators: Readonly>; - readonly status: HealthStatus; -} - -export const HEALTH_INDICATORS = Symbol('HEALTH_INDICATORS'); diff --git a/src/health/application/ports/in/health.port.ts b/src/health/application/ports/in/health.port.ts new file mode 100644 index 0000000..9d1ea54 --- /dev/null +++ b/src/health/application/ports/in/health.port.ts @@ -0,0 +1,42 @@ +import type { SystemHealth } from '../../../domain/system-health.js'; + +/** + * health capability가 **밖에 제공하는 것 전부** — inbound port. + * + * `ports/out/`과 짝이고 방향이 곧 공개 여부다. `in`은 밖에서 나를 부르는 계약이라 공개하고, + * `out`은 내가 디스크·DB·Redis를 부르는 계약이라 감춘다. 소비자는 아래 `HEALTH` Symbol만 + * 런타임에 잡고 타입은 `import type`으로 본다 — 구현 클래스가 소비자의 import 그래프에 + * 들어가지 않으므로 capability 사이 순환 참조가 생기지 않는다. + * + * ## 전에는 여기가 `GetHealthQuery` + `QueryBus`였다 + * + * `ARCHITECTURE.md`의 「전역 Command/Query 버스는 기본이 아니다」가 정한 조건 — + * 같은 메시지를 여러 inbound가 보내거나 dispatch 자체가 값을 하는 경우 — 을 만족하지 + * 못했다. 보내는 곳이 `health.controller.ts` 하나였고 handler는 `coordinator.check()`를 + * 그대로 전달했다. + * + * 버스 시절의 함정 하나가 함께 사라진다: `execute(...)`처럼 + * 타입 인자를 둘 명시하면 `@nestjs/cqrs`의 두 번째 오버로드가 잡혀 **결과 타입을 거짓말해도 + * `tsc`가 통과시켰다**(파생 프로젝트에서 실측). 메서드 시그니처에는 그 문제가 없다 — + * 결과 타입이 여기 한 번 적히고 호출부는 추론만 받는다. + */ +export interface HealthPort { + /** 활성 의존성을 모두 확인한 판정. `up`/`down`을 HTTP 상태로 옮기는 것은 호출부의 몫이다. */ + check(): Promise; + + /** + * 같은 판정을 indicator별 `1`/`0`으로 본 것. Prometheus gauge가 이 모양을 요구한다. + * + * `check()`와 나뉜 이유는 표현이 아니라 **호출 시점**이다 — scrape 콜백은 요청 경로 밖에서 + * 돌고, HTTP 상태 매핑도 예외 변환도 필요로 하지 않는다. + */ + inspectIndicators(): Promise>; +} + +/** + * 주입 토큰. + * + * `Symbol`인 이유는 interface가 런타임에 남지 않아서다. 문자열 토큰은 다른 모듈이 같은 + * 문자열을 쓰면 조용히 충돌하지만 `Symbol`은 참조 동일성으로만 식별된다. + */ +export const HEALTH = Symbol('HEALTH'); diff --git a/src/health/application/ports/out/health-indicator.port.ts b/src/health/application/ports/out/health-indicator.port.ts new file mode 100644 index 0000000..eaecacb --- /dev/null +++ b/src/health/application/ports/out/health-indicator.port.ts @@ -0,0 +1,14 @@ +import type { HealthIndicatorSnapshot } from '../../../domain/system-health.js'; + +/** + * 개별 의존성 상태를 조회하는 outbound port — **내가 밖에 요구하는 계약**이라 비공개다. + * + * 여기가 의존 역전이 실제로 값을 하는 자리다. coordinator는 RDB인지 Redis인지 디스크인지 + * 모른 채 `check()`만 부르고, 그래서 실물 의존성 없이 테스트된다. + */ +export interface HealthIndicatorPort { + readonly key: string; + check(): Promise; +} + +export const HEALTH_INDICATORS = Symbol('HEALTH_INDICATORS'); diff --git a/src/health/application/queries/get-health.query-handler.spec.ts b/src/health/application/queries/get-health.query-handler.spec.ts deleted file mode 100644 index b389ba7..0000000 --- a/src/health/application/queries/get-health.query-handler.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { GetHealthQueryHandler } from './get-health.query-handler.js'; - -describe('GetHealthQueryHandler', () => { - it('delegates transport-independent health orchestration to the coordinator', async () => { - const result = { indicators: {}, status: 'up' }; - const health = { check: vi.fn().mockResolvedValue(result) }; - const handler = new GetHealthQueryHandler(health as never); - - await expect(handler.execute()).resolves.toBe(result); - expect(health.check).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/health/application/queries/get-health.query-handler.ts b/src/health/application/queries/get-health.query-handler.ts deleted file mode 100644 index 4222202..0000000 --- a/src/health/application/queries/get-health.query-handler.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; - -import { HealthCoordinator } from '../health.coordinator.js'; -import type { SystemHealth } from '../ports/health-indicator.port.js'; -import { GetHealthQuery } from './get-health.query.js'; - -/** HTTP와 무관하게 health 조회 사용 사례를 실행한다. */ -@QueryHandler(GetHealthQuery) -export class GetHealthQueryHandler implements IQueryHandler { - constructor(private readonly health: HealthCoordinator) {} - - execute(): Promise { - return this.health.check(); - } -} diff --git a/src/health/application/queries/get-health.query.ts b/src/health/application/queries/get-health.query.ts deleted file mode 100644 index abdbae0..0000000 --- a/src/health/application/queries/get-health.query.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** HTTP와 무관한 헬스 조회 의도. Inbound adapter는 이 질의만 전송한다. */ -export class GetHealthQuery {} diff --git a/src/health/domain/system-health.ts b/src/health/domain/system-health.ts new file mode 100644 index 0000000..7899a69 --- /dev/null +++ b/src/health/domain/system-health.ts @@ -0,0 +1,21 @@ +/** + * health capability의 domain 타입. + * + * `application/ports/in/`과 함께 **이 capability가 밖에 공개하는 표면**이다. 두 방향의 port가 + * 모두 이 타입을 쓰기 때문에 여기에 둔다 — inbound port(`health.port.ts`)가 반환하고, + * outbound port(`ports/out/health-indicator.port.ts`)가 개별 indicator에서 받는다. + * 비공개인 `ports/out/`에 두면 소비자가 결과 타입을 이름 부르려고 비공개 파일을 열어야 한다. + * + * 타입과 순수 함수만 둔다. 상태를 읽거나 외부와 말하는 순간 그것은 `application/`이다. + */ +export type HealthStatus = 'down' | 'up'; + +export interface HealthIndicatorSnapshot { + readonly message?: string; + readonly status: HealthStatus; +} + +export interface SystemHealth { + readonly indicators: Readonly>; + readonly status: HealthStatus; +} diff --git a/src/health/health-metrics.service.ts b/src/health/health-metrics.service.ts index b749cec..40ca181 100644 --- a/src/health/health-metrics.service.ts +++ b/src/health/health-metrics.service.ts @@ -1,9 +1,9 @@ -import { Injectable, type OnModuleInit } from '@nestjs/common'; +import { Inject, Injectable, type OnModuleInit } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { Gauge } from '@prometheus-io/client'; import { MetricsService } from '../infra/metrics/metrics.service.js'; -import { HealthCoordinator } from './application/health.coordinator.js'; +import { HEALTH, type HealthPort } from './application/ports/in/health.port.js'; /** * 헬스체크 결과를 Prometheus Gauge 메트릭으로 노출하는 서비스 @@ -21,7 +21,7 @@ import { HealthCoordinator } from './application/health.coordinator.js'; * → registry.metrics() * → @prometheus-io/client가 이 Gauge의 collect 콜백 자동 실행 * → gauge.reset() (이전 값 초기화) - * → healthCoordinator.inspectIndicators() (DB, Redis 등 체크) + * → health.inspectIndicators() (DB, Redis 등 체크) * → gauge.set({ indicator: 'database' }, 1) 등 값 세팅 * → 텍스트로 직렬화하여 반환 * @@ -48,7 +48,7 @@ export class HealthMetricsService implements OnModuleInit { * ModuleRef + onModuleInit 조합으로 느슨하게 연결한다. */ private readonly moduleRef: ModuleRef, - private readonly healthCoordinator: HealthCoordinator, + @Inject(HEALTH) private readonly health: HealthPort, ) {} onModuleInit(): void { @@ -104,7 +104,7 @@ export class HealthMetricsService implements OnModuleInit { collect: async () => { gauge.reset(); - const statuses = await this.healthCoordinator.inspectIndicators(); + const statuses = await this.health.inspectIndicators(); Object.entries(statuses).forEach(([indicator, value]) => { gauge.set({ indicator }, value); diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 5b755de..89096a2 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -18,11 +18,11 @@ import { MongodbHealthAdapter } from './adapters/out/mongodb-health.adapter.js'; import { RdbHealthAdapter } from './adapters/out/rdb-health.adapter.js'; import { RedisHealthAdapter } from './adapters/out/redis-health.adapter.js'; import { HealthCoordinator } from './application/health.coordinator.js'; +import { HEALTH } from './application/ports/in/health.port.js'; import { HEALTH_INDICATORS, type HealthIndicatorPort, -} from './application/ports/health-indicator.port.js'; -import { GetHealthQueryHandler } from './application/queries/get-health.query-handler.js'; +} from './application/ports/out/health-indicator.port.js'; import { HealthMetricsService } from './health-metrics.service.js'; const healthAdapterTypes: Type[] = [ @@ -37,21 +37,25 @@ if (featureFlags.mongodb) { } /** - * System health 헥사고날 모듈. + * System health capability의 composition root — 두 방향의 port에 구현을 묶는 유일한 지점. * - * inbound adapter는 QueryBus를 통해 진입하고, Coordinator는 기술별 outbound adapter를 - * 모른 채 `HealthIndicatorPort` 목록만 실행한다. HTTP·CLI·Prometheus가 같은 판단을 공유한다. + * inbound adapter는 `HEALTH` 토큰으로 진입하고, Coordinator는 기술별 outbound adapter를 + * 모른 채 `HealthIndicatorPort` 목록만 실행한다. HTTP·Prometheus가 같은 판단을 공유한다. + * + * `exports`에 토큰만 있고 `HealthCoordinator` 클래스는 없다. 소비자는 `@Inject(HEALTH)`로 받고 + * 타입은 `import type`으로 보므로 구현 클래스가 소비자의 런타임 import 그래프에 들어가지 않는다. + * `HealthMetricsService`는 클래스로 나가는데, 이것이 `MetricsModule`에 붙는 gauge 등록 + * 그 자체이고 대체할 계약이 없기 때문이다. */ @Module({ controllers: [HealthController], imports: [RdbDatabaseModule, RedisModule], - exports: [HealthCoordinator, HealthMetricsService], + exports: [HEALTH, HealthMetricsService], providers: [ { provide: DISK_SPACE_PROBE, useValue: nodeDiskSpaceProbe }, { provide: MEMORY_USAGE_PROBE, useValue: processMemoryUsageProbe }, ...healthAdapterTypes, - GetHealthQueryHandler, - HealthCoordinator, + { provide: HEALTH, useClass: HealthCoordinator }, HealthMetricsService, { inject: healthAdapterTypes, From afa7161a6213e0294dc98d543175ab38be86efbc Mon Sep 17 00:00:00 2001 From: ifLov Date: Thu, 3 Sep 2026 15:05:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(health):=20=EB=A6=AC=EB=B7=B0=20finding?= =?UTF-8?q?s=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EC=9A=B0=ED=9A=8C,=20=EA=B3=B5=EA=B0=9C=20=ED=91=9C=EB=A9=B4,?= =?UTF-8?q?=20stale=20=EC=A0=95=EC=B1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 리뷰 레인이 여섯 건을 냈다. 다섯 건을 고치고 한 건은 근거를 적어 문서의 「아직 문서를 따라가지 못하는 곳」표에 남겼다. P1 — cross-capability adapter 규칙이 깊은 경로에서 우회됐다 `../`를 1~4번 쓴 여섯 패턴만 열거해서, 여섯 단계 깊이의 위반이 `exit 0`으로 통과했다. 열거를 늘리는 것은 경계를 옮길 뿐이라 glob의 실제 동작을 재보고 다시 썼다. - 이 rule의 glob은 import 문자열을 그대로 본다. 경로를 resolve하지 않으므로 `..`가 리터럴 segment다. - `**`는 `..`를 건너지 못한다. `**/*/adapters/**` 하나로 끝내려 하면 깊이 1도 통과한다. - 리터럴 `../`로 시작한 뒤의 `**`는 남은 `..`를 건넌다. 그래서 `../**/*/adapters/**`가 깊이와 무관하게 문다. - 이미 capability 밖에 있는 파일(`src/app.module.ts`)의 `./health/adapters/**`를 잡으려면 `./`로 시작하는 짝이 따로 필요하다. 네 패턴으로 다시 확인했다: 깊이 1·6, `test/`·`scripts/`의 `../src//adapters/**`, src root의 `.//adapters/**` 모두 차단. 자기 capability의 `./adapters/**`· `../adapters/**`는 통과. 오검출 하나(`../../adapters/**`)는 남는데, 그 자리는 `application/`·`domain/`뿐이고 역방향 차단이 이미 막는다 — 근거를 config 주석에 적었다. P2 — Prometheus의 1/0 표현이 public inbound port로 샜다 `HealthPort.inspectIndicators(): Promise>`를 지웠다. gauge encoding을 capability의 공개 계약으로 만들면 metrics backend를 바꿀 때 health의 외부 API가 따라 바뀐다. port는 `check(): Promise` 하나만 주고, up/down → 1/0 변환은 그것을 요구하는 `health-metrics.service.ts`가 소유한다. coordinator에서도 지웠고 spec을 adapter 쪽으로 옮겼다. 대역이 실제 `HealthPort`를 구조적으로 만족하게 되면서 `as never`도 필요 없어졌다. P2 — module이 concrete service를 다시 공개했다 `exports: [HEALTH, HealthMetricsService]` → `exports: [HEALTH]`. 소비자가 없었고, `HealthMetricsService`는 gauge를 등록하는 이 capability의 inbound infrastructure adapter라 밖에서 주입받을 것이 아니다. e2e의 `app_health_check_status` 검증이 그대로 통과한다. P3 — `pnpm-workspace.yaml`의 `@nestjs/cqrs@12.0.0` minimumReleaseAgeExclude 제거. P3 — README 다이어그램이 CLI를 Controller에 묶었다. `HTTP / CLI / message → inbound adapter (Controller, CLI command, consumer)`로 고쳐 ARCHITECTURE.md의 고정 문법과 맞춘다. 고치지 않은 것 `health-metrics.service.ts`가 optional한 `MetricsService`를 `ModuleRef.get(..., { strict: false })`로 찾는다. optional dependency와 실패 모드가 module wiring이 아니라 DI lookup에 숨는 것이 맞다. 다만 고치려면 metrics 유무에 따른 provider 구성을 바꿔야 하고 그것은 health 밖의 결정이라 「이 템플릿이 아직 문서를 따라가지 못하는 곳」표에 한 줄로 남겼다. 검증: lint, typecheck, test(35 files / 318), check:scaffold, check:observability, build, test:e2e(2 files / 20, Docker postgres) 통과. lint 규칙은 위반 파일을 실제로 만들어 확인했고 probe는 전부 지웠다. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BpFLpds7xSWRhwheHcyZYf --- ARCHITECTURE.md | 19 ++--- README.md | 4 +- eslint.config.mjs | 28 ++++++-- pnpm-workspace.yaml | 1 - .../in/http/health.controller.spec.ts | 5 +- .../application/health.coordinator.spec.ts | 9 --- src/health/application/health.coordinator.ts | 11 --- .../application/ports/in/health.port.ts | 14 ++-- src/health/health-metrics.service.spec.ts | 71 ++++++++++++------- src/health/health-metrics.service.ts | 11 +-- src/health/health.module.ts | 10 +-- 11 files changed, 102 insertions(+), 81 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9f7b5ad..0edcaea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -264,11 +264,14 @@ test surface는 review에서 함께 확인한다. 문서를 먼저 고치고 코드를 뒤에 옮긴다. 지금 어긋난 자리를 적어둔다. -**현재 비어 있다.** 2026-09-03에 다섯 줄을 모두 닫았다 — `src/health/`가 `QueryBus` + -`GetHealthQuery` 대신 `application/ports/in/health.port.ts`로 진입하고, `ports/`가 `in`/`out`으로 -나뉘었으며, `@nestjs/cqrs`가 의존성에서 빠졌고, 「Tests and enforcement」의 4번 lint 규칙이 -들어왔다. `src/architecture-rule-fixture/`의 빈 디렉토리 셋은 git이 추적하지 않던 것이라 -체크아웃에서만 지웠다 — 새 clone에는 원래 없다. - -다음에 어긋나는 것이 생기면 여기에 표로 적는다. 적어두지 않으면 다음 프로젝트가 이 코드를 -보고 이미 폐기된 형태로 시작한다. +2026-09-03에 다섯 줄을 닫았다 — `src/health/`가 `QueryBus` + `GetHealthQuery` 대신 +`application/ports/in/health.port.ts`로 진입하고, `ports/`가 `in`/`out`으로 나뉘었으며, +`@nestjs/cqrs`가 의존성에서 빠졌고, 「Tests and enforcement」의 4번 lint 규칙이 들어왔다. +`src/architecture-rule-fixture/`의 빈 디렉토리 셋은 git이 추적하지 않던 것이라 체크아웃에서만 +지웠다 — 새 clone에는 원래 없다. + +리뷰가 남긴 한 줄이 아직 열려 있다. + +| 자리 | 어긋난 것 | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/health/health-metrics.service.ts` | optional한 `MetricsService`를 `ModuleRef.get(..., { strict: false })`로 찾는다. optional dependency와 그 실패 모드가 module wiring이 아니라 DI lookup 안에 숨는다. 위 「Composition and platform modules」는 그런 선택을 composition root에 남기라고 한다. 고치려면 metrics 유무에 따른 provider 구성을 바꿔야 하고 그것은 health 밖의 결정이라 별도 작업으로 둔다 | diff --git a/README.md b/README.md index b89d0cf..c8f886e 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,8 @@ pnpm scaffold:select --orm=drizzle --database=mariadb --apply 전체 규칙과 새 capability 작성 기준은 [ARCHITECTURE.md](./ARCHITECTURE.md)가 단일 진실 원천입니다. ```text -HTTP / CLI - → Controller +HTTP / CLI / message + → inbound adapter (Controller, CLI command, consumer) → inbound port (application/ports/in) → application service (inbound port의 구현) → outbound port (application/ports/out) diff --git a/eslint.config.mjs b/eslint.config.mjs index 208270b..13f8526 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -196,6 +196,26 @@ export default tseslint.config( // 막는 것은 **capability 이름을 거쳐 adapters로 들어가는 경로**다. 자기 capability 안의 // `./adapters/**`·`../adapters/**`는 걸리지 않는다 — module이 자기 adapter를 조립하는 // 것은 composition root의 일이다. + // + // ## ⚠ glob 네 개가 필요한 이유 (2026-09-03 실측) + // + // 이 rule의 glob은 import 문자열을 **그대로** 본다. 경로를 resolve하지 않으므로 `..`가 + // 리터럴 segment이고, 거기서 둘이 따라온다. + // + // - `**`는 `..`를 **건너지 못한다.** `**/*/adapters/**` 하나로 끝내려 하면 상대 경로 + // 위반을 전부 놓친다(실측: 깊이 1도 통과). 반면 리터럴 `../`로 시작한 뒤의 `**`는 + // 남은 `..`를 건넌다 — 그래서 `../**/*/adapters/**`가 깊이와 무관하게 문다. + // - 그래서 `../`로 시작하는 짝과 `./`로 시작하는 짝이 각각 필요하다. 앞의 둘이 + // capability 안에서 나가는 경로를, 뒤의 둘이 `src/app.module.ts`처럼 이미 capability + // 밖에 있는 파일의 `./health/adapters/**`를 잡는다. + // + // 깊이를 `../` 개수로 열거하면 **그보다 깊은 디렉토리가 조용히 빠져나간다.** 처음에는 + // 네 단계까지만 열거돼 있었고, 리뷰가 여섯 단계짜리 위반을 만들어 통과시켰다. + // + // 반대 방향의 오검출 하나는 남긴다: `*`가 `..` segment에도 매치되므로 `../../adapters/**` + // 처럼 자기 adapter를 두 단계 위에서 부르는 형태도 걸린다. 그 자리는 `application/`· + // `domain/`뿐인데 아래 역방향 차단이 이미 막고 있고, adapter끼리는 `adapters` segment를 + // 지나지 않고 서로 닿는다. files: ['src/**/*.ts', 'scripts/**/*.ts', 'test/**/*.ts'], rules: { 'no-restricted-imports': [ @@ -205,11 +225,9 @@ export default tseslint.config( { group: [ '../*/adapters/**', - '../../*/adapters/**', - '../../../*/adapters/**', - '../../../../*/adapters/**', - '../src/*/adapters/**', - '../../src/*/adapters/**', + '../**/*/adapters/**', + './*/adapters/**', + './**/*/adapters/**', ], message: 'ARCHITECTURE.md: 다른 capability의 adapter를 직접 import하지 않는다. 그쪽이 공개한 application/ports/in 계약이나 domain 타입을 쓸 것. 테스트도 production interface를 쓴다.', diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a1d8334..4d2c98e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,7 +19,6 @@ minimumReleaseAgeExclude: - '@nestjs/common@12.0.1' - '@nestjs/config@12.0.0' - '@nestjs/core@12.0.1' - - '@nestjs/cqrs@12.0.0' - '@nestjs/mapped-types@12.0.0' - '@nestjs/mongoose@12.0.0' - '@nestjs/platform-express@12.0.1' diff --git a/src/health/adapters/in/http/health.controller.spec.ts b/src/health/adapters/in/http/health.controller.spec.ts index 99ac7c8..38557bf 100644 --- a/src/health/adapters/in/http/health.controller.spec.ts +++ b/src/health/adapters/in/http/health.controller.spec.ts @@ -9,10 +9,7 @@ import { HealthController } from './health.controller.js'; * 대역이 `check`라는 메서드 이름을 갖는 것이 값이다 — 버스 시절에는 `execute()` 하나였다. */ function port(result: SystemHealth): HealthPort { - return { - check: vi.fn().mockResolvedValue(result), - inspectIndicators: vi.fn(), - }; + return { check: vi.fn().mockResolvedValue(result) }; } describe('HealthController', () => { diff --git a/src/health/application/health.coordinator.spec.ts b/src/health/application/health.coordinator.spec.ts index 904d538..85e5f78 100644 --- a/src/health/application/health.coordinator.spec.ts +++ b/src/health/application/health.coordinator.spec.ts @@ -52,13 +52,4 @@ describe('HealthCoordinator', () => { status: 'down', }); }); - - it('maps snapshots to prometheus gauge values', async () => { - const coordinator = new HealthCoordinator([ - indicator('database', { status: 'up' }), - indicator('redis', { status: 'down' }), - ]); - - await expect(coordinator.inspectIndicators()).resolves.toEqual({ database: 1, redis: 0 }); - }); }); diff --git a/src/health/application/health.coordinator.ts b/src/health/application/health.coordinator.ts index 359dd5e..103f384 100644 --- a/src/health/application/health.coordinator.ts +++ b/src/health/application/health.coordinator.ts @@ -31,17 +31,6 @@ export class HealthCoordinator implements HealthPort { return { indicators: snapshots, status }; } - async inspectIndicators(): Promise> { - const health = await this.check(); - const statusByIndicator: Record = {}; - - for (const [key, snapshot] of Object.entries(health.indicators)) { - statusByIndicator[key] = snapshot.status === 'up' ? 1 : 0; - } - - return statusByIndicator; - } - private async checkIndicator(indicator: HealthIndicatorPort): Promise { try { return await indicator.check(); diff --git a/src/health/application/ports/in/health.port.ts b/src/health/application/ports/in/health.port.ts index 9d1ea54..45a6486 100644 --- a/src/health/application/ports/in/health.port.ts +++ b/src/health/application/ports/in/health.port.ts @@ -21,16 +21,16 @@ import type { SystemHealth } from '../../../domain/system-health.js'; * 결과 타입이 여기 한 번 적히고 호출부는 추론만 받는다. */ export interface HealthPort { - /** 활성 의존성을 모두 확인한 판정. `up`/`down`을 HTTP 상태로 옮기는 것은 호출부의 몫이다. */ - check(): Promise; - /** - * 같은 판정을 indicator별 `1`/`0`으로 본 것. Prometheus gauge가 이 모양을 요구한다. + * 활성 의존성을 모두 확인한 판정. `up`/`down`을 무엇으로 옮길지는 호출부가 정한다 — + * HTTP adapter는 `200`/`503`으로, Prometheus adapter는 gauge의 `1`/`0`으로 옮긴다. * - * `check()`와 나뉜 이유는 표현이 아니라 **호출 시점**이다 — scrape 콜백은 요청 경로 밖에서 - * 돌고, HTTP 상태 매핑도 예외 변환도 필요로 하지 않는다. + * ⚠ **그 숫자 encoding을 여기에 두지 않는다.** 한때 `inspectIndicators(): Promise>`가 이 계약에 있었고, 그것은 gauge 표현을 capability의 공개 API로 + * 만드는 것이었다 — metrics backend를 바꾸면 health의 외부 계약이 따라 바뀐다. 표현은 + * 그것을 요구하는 adapter가 소유한다. */ - inspectIndicators(): Promise>; + check(): Promise; } /** diff --git a/src/health/health-metrics.service.spec.ts b/src/health/health-metrics.service.spec.ts index 04e2dac..9653442 100644 --- a/src/health/health-metrics.service.spec.ts +++ b/src/health/health-metrics.service.spec.ts @@ -1,9 +1,15 @@ import type { ModuleRef } from '@nestjs/core'; import { Registry } from '@prometheus-io/client'; +import type { SystemHealth } from './domain/system-health.js'; import { HealthMetricsService } from './health-metrics.service.js'; -function createMocks(inspectResult: Record = { database: 1, redis: 0 }) { +const upAndDown: SystemHealth = { + indicators: { database: { status: 'up' }, redis: { status: 'down' } }, + status: 'down', +}; + +function createMocks(health: SystemHealth = upAndDown) { const registry = new Registry(); const metricsService = { @@ -11,20 +17,17 @@ function createMocks(inspectResult: Record = { database: 1, redi getPrefix: () => 'test_', }; - const healthCoordinator = { - inspectIndicators: vi.fn().mockResolvedValue(inspectResult), + const healthPort = { + check: vi.fn().mockResolvedValue(health), }; const moduleRef = { get: vi.fn().mockReturnValue(metricsService), }; - const service = new HealthMetricsService( - moduleRef as unknown as ModuleRef, - healthCoordinator as never, - ); + const service = new HealthMetricsService(moduleRef as unknown as ModuleRef, healthPort); service.onModuleInit(); - return { service, registry, healthCoordinator, moduleRef }; + return { service, registry, healthPort, moduleRef }; } describe('HealthMetricsService', () => { @@ -41,23 +44,17 @@ describe('HealthMetricsService', () => { getRegistry: () => registry, getPrefix: () => 'test_', }; - const healthCoordinator = { - inspectIndicators: vi.fn().mockResolvedValue({}), + const healthPort = { + check: vi.fn().mockResolvedValue({ indicators: {}, status: 'up' }), }; const moduleRef = { get: vi.fn().mockReturnValue(metricsService), }; - const first = new HealthMetricsService( - moduleRef as unknown as ModuleRef, - healthCoordinator as never, - ); + const first = new HealthMetricsService(moduleRef as unknown as ModuleRef, healthPort); first.onModuleInit(); - const second = new HealthMetricsService( - moduleRef as unknown as ModuleRef, - healthCoordinator as never, - ); + const second = new HealthMetricsService(moduleRef as unknown as ModuleRef, healthPort); second.onModuleInit(); // 에러 없이 두 번 생성 가능 @@ -67,7 +64,14 @@ describe('HealthMetricsService', () => { describe('collect callback (triggered on scrape)', () => { it('outputs indicator values on registry.metrics()', async () => { - const { registry } = createMocks({ database: 1, redis: 0, memory_heap: 1 }); + const { registry } = createMocks({ + indicators: { + database: { status: 'up' }, + redis: { status: 'down' }, + memory_heap: { status: 'up' }, + }, + status: 'down', + }); const metrics = await registry.metrics(); @@ -77,22 +81,41 @@ describe('HealthMetricsService', () => { expect(metrics).toContain('indicator="memory_heap"'); }); - it('calls inspectIndicators on each scrape', async () => { - const { registry, healthCoordinator } = createMocks(); + /** + * up/down → 1/0 encoding은 이 adapter가 소유한다. inbound port는 `SystemHealth`만 준다 — + * 그래야 metrics backend를 바꿔도 health capability의 공개 계약이 그대로다. + */ + it('owns the up/down to 1/0 encoding', async () => { + const { registry } = createMocks(); + + const metrics = await registry.metrics(); + + expect(metrics).toContain('test_health_check_status{indicator="database"} 1'); + expect(metrics).toContain('test_health_check_status{indicator="redis"} 0'); + }); + + it('calls the health port on each scrape', async () => { + const { registry, healthPort } = createMocks(); await registry.metrics(); await registry.metrics(); - expect(healthCoordinator.inspectIndicators).toHaveBeenCalledTimes(2); + expect(healthPort.check).toHaveBeenCalledTimes(2); }); it('resets previous values before setting new ones', async () => { - const { registry, healthCoordinator } = createMocks({ database: 1, redis: 1 }); + const { registry, healthPort } = createMocks({ + indicators: { database: { status: 'up' }, redis: { status: 'up' } }, + status: 'up', + }); await registry.metrics(); // 두 번째 스크랩에서 redis가 사라짐 - healthCoordinator.inspectIndicators.mockResolvedValue({ database: 1 }); + healthPort.check.mockResolvedValue({ + indicators: { database: { status: 'up' } }, + status: 'up', + }); const metrics = await registry.metrics(); expect(metrics).toContain('indicator="database"'); diff --git a/src/health/health-metrics.service.ts b/src/health/health-metrics.service.ts index 40ca181..447d344 100644 --- a/src/health/health-metrics.service.ts +++ b/src/health/health-metrics.service.ts @@ -21,7 +21,8 @@ import { HEALTH, type HealthPort } from './application/ports/in/health.port.js'; * → registry.metrics() * → @prometheus-io/client가 이 Gauge의 collect 콜백 자동 실행 * → gauge.reset() (이전 값 초기화) - * → health.inspectIndicators() (DB, Redis 등 체크) + * → health.check() (DB, Redis 등 체크) + * → up/down을 1/0으로 옮긴다 (이 파일이 소유하는 표현) * → gauge.set({ indicator: 'database' }, 1) 등 값 세팅 * → 텍스트로 직렬화하여 반환 * @@ -98,16 +99,16 @@ export class HealthMetricsService implements OnModuleInit { * - collect: 스크랩할 때만 실행, 항상 최신 데이터, 관리 불필요 * * reset()이 필요한 이유: - * Redis를 비활성화하면 inspectIndicators()가 redis를 반환하지 않는데, + * Redis를 비활성화하면 check()가 redis indicator를 반환하지 않는데, * reset 없이면 이전 스크랩의 redis 값이 그대로 남아 잘못된 메트릭 노출 */ collect: async () => { gauge.reset(); - const statuses = await this.health.inspectIndicators(); + const health = await this.health.check(); - Object.entries(statuses).forEach(([indicator, value]) => { - gauge.set({ indicator }, value); + Object.entries(health.indicators).forEach(([indicator, snapshot]) => { + gauge.set({ indicator }, snapshot.status === 'up' ? 1 : 0); }); }, help: 'Health indicator status (1=up, 0=down)', diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 89096a2..571d32a 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -42,15 +42,15 @@ if (featureFlags.mongodb) { * inbound adapter는 `HEALTH` 토큰으로 진입하고, Coordinator는 기술별 outbound adapter를 * 모른 채 `HealthIndicatorPort` 목록만 실행한다. HTTP·Prometheus가 같은 판단을 공유한다. * - * `exports`에 토큰만 있고 `HealthCoordinator` 클래스는 없다. 소비자는 `@Inject(HEALTH)`로 받고 - * 타입은 `import type`으로 보므로 구현 클래스가 소비자의 런타임 import 그래프에 들어가지 않는다. - * `HealthMetricsService`는 클래스로 나가는데, 이것이 `MetricsModule`에 붙는 gauge 등록 - * 그 자체이고 대체할 계약이 없기 때문이다. + * `exports`에는 `HEALTH` 토큰 하나만 있다. 소비자는 `@Inject(HEALTH)`로 받고 타입은 + * `import type`으로 보므로 구현 클래스가 소비자의 런타임 import 그래프에 들어가지 않는다. + * `HealthMetricsService`도 나가지 않는다 — 그것은 `MetricsService`의 registry에 gauge를 붙이는 + * 이 capability의 inbound infrastructure adapter이고, 밖에서 주입받을 것이 아니다. */ @Module({ controllers: [HealthController], imports: [RdbDatabaseModule, RedisModule], - exports: [HEALTH, HealthMetricsService], + exports: [HEALTH], providers: [ { provide: DISK_SPACE_PROBE, useValue: nodeDiskSpaceProbe }, { provide: MEMORY_USAGE_PROBE, useValue: processMemoryUsageProbe },