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 486f3da..0edcaea 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 안에서 끝날 수 있다. + +## Outbound port — 내가 밖에 요구하는 계약 -## Mandatory outbound ports +Application이 외부 I/O를 필요로 하면 구현체가 하나여도 port를 반드시 둔다. +`application/ports/out/`에 두고 adapter가 구현한다. -Application이 외부 I/O를 필요로 하면 구현체가 하나여도 port를 반드시 둔다. Port는 application이 -외부 세계에 요구하는 capability 언어이며 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,57 @@ 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/` — 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` — 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」의 ⚠ 참고). + +## 이 템플릿이 아직 문서를 따라가지 못하는 곳 + +문서를 먼저 고치고 코드를 뒤에 옮긴다. 지금 어긋난 자리를 적어둔다. + +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/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..c8f886e 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 프로필 @@ -77,20 +77,25 @@ pnpm scaffold:select --orm=drizzle --database=mariadb --apply 전체 규칙과 새 capability 작성 기준은 [ARCHITECTURE.md](./ARCHITECTURE.md)가 단일 진실 원천입니다. ```text -HTTP / CLI - → Controller / Command - → CommandHandler | QueryHandler - → Application service or coordinator - → Port +HTTP / CLI / message + → inbound adapter (Controller, CLI command, consumer) + → 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/eslint.config.mjs b/eslint.config.mjs index f034dd6..13f8526 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -173,6 +173,70 @@ 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의 일이다. + // + // ## ⚠ 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': [ + 'error', + { + patterns: [ + { + group: [ + '../*/adapters/**', + '../**/*/adapters/**', + './*/adapters/**', + './**/*/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'], 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/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/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..38557bf --- /dev/null +++ b/src/health/adapters/in/http/health.controller.spec.ts @@ -0,0 +1,29 @@ +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) }; +} + +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..85e5f78 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 { @@ -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 037336b..103f384 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[], ) {} @@ -30,17 +31,6 @@ export class HealthCoordinator { 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/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..45a6486 --- /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 adapter는 `200`/`503`으로, Prometheus adapter는 gauge의 `1`/`0`으로 옮긴다. + * + * ⚠ **그 숫자 encoding을 여기에 두지 않는다.** 한때 `inspectIndicators(): Promise>`가 이 계약에 있었고, 그것은 gauge 표현을 capability의 공개 API로 + * 만드는 것이었다 — metrics backend를 바꾸면 health의 외부 계약이 따라 바뀐다. 표현은 + * 그것을 요구하는 adapter가 소유한다. + */ + check(): 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.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 b749cec..447d344 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,8 @@ import { HealthCoordinator } from './application/health.coordinator.js'; * → registry.metrics() * → @prometheus-io/client가 이 Gauge의 collect 콜백 자동 실행 * → gauge.reset() (이전 값 초기화) - * → healthCoordinator.inspectIndicators() (DB, Redis 등 체크) + * → health.check() (DB, Redis 등 체크) + * → up/down을 1/0으로 옮긴다 (이 파일이 소유하는 표현) * → gauge.set({ indicator: 'database' }, 1) 등 값 세팅 * → 텍스트로 직렬화하여 반환 * @@ -48,7 +49,7 @@ export class HealthMetricsService implements OnModuleInit { * ModuleRef + onModuleInit 조합으로 느슨하게 연결한다. */ private readonly moduleRef: ModuleRef, - private readonly healthCoordinator: HealthCoordinator, + @Inject(HEALTH) private readonly health: HealthPort, ) {} onModuleInit(): void { @@ -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.healthCoordinator.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 5b755de..571d32a 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`에는 `HEALTH` 토큰 하나만 있다. 소비자는 `@Inject(HEALTH)`로 받고 타입은 + * `import type`으로 보므로 구현 클래스가 소비자의 런타임 import 그래프에 들어가지 않는다. + * `HealthMetricsService`도 나가지 않는다 — 그것은 `MetricsService`의 registry에 gauge를 붙이는 + * 이 capability의 inbound infrastructure adapter이고, 밖에서 주입받을 것이 아니다. */ @Module({ controllers: [HealthController], imports: [RdbDatabaseModule, RedisModule], - exports: [HealthCoordinator, HealthMetricsService], + exports: [HEALTH], providers: [ { provide: DISK_SPACE_PROBE, useValue: nodeDiskSpaceProbe }, { provide: MEMORY_USAGE_PROBE, useValue: processMemoryUsageProbe }, ...healthAdapterTypes, - GetHealthQueryHandler, - HealthCoordinator, + { provide: HEALTH, useClass: HealthCoordinator }, HealthMetricsService, { inject: healthAdapterTypes,