Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,30 @@

```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
```

- 의존성은 바깥 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는 요구사항이 있을 때만 도입한다.

Expand Down
181 changes: 146 additions & 35 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

16 changes: 9 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 둔다.
Expand Down Expand Up @@ -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는 제품 요구가 있을 때만 추가한다.

Expand Down
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 프로필
Expand Down Expand Up @@ -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 동시 쓰기는 기본값이 아닙니다.
Expand Down
64 changes: 64 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 0 additions & 22 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 0 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -44,7 +43,6 @@ const {
},
},
}),
CqrsModule.forRoot(),
LoggerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
Expand Down
29 changes: 29 additions & 0 deletions src/health/adapters/in/http/health.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
13 changes: 6 additions & 7 deletions src/health/adapters/in/http/health.controller.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
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')
@SkipThrottle()
@ApiOperation({ summary: 'Application health check' })
@ApiOkResponse({ description: 'Returns application health information' })
async check(): Promise<SystemHealth> {
const result = await this.queryBus.execute<GetHealthQuery, SystemHealth>(new GetHealthQuery());
const result = await this.health.check();

if (result.status === 'down') {
throw new ServiceUnavailableException('Health check failed');
Expand Down
6 changes: 2 additions & 4 deletions src/health/adapters/out/disk-health.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 2 additions & 4 deletions src/health/adapters/out/memory-health.adapter.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
6 changes: 2 additions & 4 deletions src/health/adapters/out/mongodb-health.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading