with one click
nestjs-cqrs
NestJS 백엔드 기능 구현 시 CQRS + UseCase 아키텍처 패턴을 적용합니다.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Menu
NestJS 백엔드 기능 구현 시 CQRS + UseCase 아키텍처 패턴을 적용합니다.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
TypeScript/JavaScript 코드 작성 시 일반적인 코딩 컨벤션을 적용합니다.
TypeScript 파일의 모든 메서드에 JSDoc 주석을 추가합니다.
Git 커밋 메시지 및 브랜치 네이밍 컨벤션. 커밋, 브랜치 생성 시 참조합니다.
PR 생성 전 코드 리뷰를 수행하고 이슈가 없으면 PR을 생성합니다.
Based on SOC occupation classification
| name | nestjs-cqrs |
| description | NestJS 백엔드 기능 구현 시 CQRS + UseCase 아키텍처 패턴을 적용합니다. |
| triggers | ["백엔드 기능 추가","API 추가","NestJS","UseCase","CQRS","엔드포인트 추가"] |
새 기능을 src/<feature>/ 단위로 구성합니다.
src/<feature>/
├── <feature>.module.ts
├── <feature>.error.ts
├── entities/
│ └── <name>.entity.ts
├── presenter/
│ └── http/
│ ├── <feature>.http.controller.ts
│ ├── <feature>.path.presenter.ts
│ └── dto/
│ ├── <action>-request.dto.ts
│ └── <action>-response.dto.ts
└── application/
├── use-cases/
│ └── <action>.use-case.ts
├── commands/
│ └── <action>.command.ts
└── queries/
└── <action>.query.ts
라우트 경로와 API 태그를 상수로 관리합니다.
// src/<feature>/presenter/http/<feature>.path.presenter.ts
export const ToolsRouter = {
Root: 'tools',
HttpApiTags: 'Tools',
Http: {
GetList: '',
GetOne: ':id',
Create: '',
Update: ':id',
Delete: ':id',
},
} as const;
UseCase를 호출하는 얇은 레이어. 비즈니스 로직 없음.
// src/<feature>/presenter/http/<feature>.http.controller.ts
@ApiTags(ToolsRouter.HttpApiTags)
@Controller(ToolsRouter.Root)
export class ToolsHttpController {
constructor(
private readonly getToolListUseCase: GetToolListUseCase,
private readonly createToolUseCase: CreateToolUseCase,
) {}
@ApiOperation({ summary: '도구 목록 조회' })
@Get(ToolsRouter.Http.GetList)
async getList(): Promise<ToolListResponseDto> {
return this.getToolListUseCase.execute();
}
@ApiOperation({ summary: '도구 생성' })
@Post(ToolsRouter.Http.Create)
async create(@Body() bodyDto: CreateToolRequestDto): Promise<CreateToolResponseDto> {
return this.createToolUseCase.execute({ bodyDto });
}
}
@ApiBearerAuth('accessToken')
@UseGuards(JwtAuthGuard)
@Get(ToolsRouter.Http.GetOne)
async getOne(
@User() user: { id: number },
@Param('id') id: string,
): Promise<ToolResponseDto> {
return this.getToolUseCase.execute({ userId: user.id, toolId: id });
}
비즈니스 로직을 담당. execute 메서드를 진입점으로, 세부 동작은 개별 메서드로 분리합니다.
// src/<feature>/application/use-cases/<action>.use-case.ts
@Injectable()
export class CreateToolUseCase {
constructor(
private readonly commandBus: TypedCommandBus<SaveToolCommand>,
private readonly queryBus: TypedQueryBus<GetToolByNameQuery>,
) {}
async execute(props: CreateToolUseCaseProps): Promise<CreateToolResponseDto> {
const { bodyDto } = props;
await this.checkNameDuplication(bodyDto.name);
const tool = await this.saveTool(bodyDto);
return this.buildResponseDto(tool);
}
/**
* 이름 중복 체크
*
* @param {string} name 도구 이름
* @throws {AppException} 이미 존재하는 이름인 경우
*/
async checkNameDuplication(name: string): Promise<void> {
const existing = await this.queryBus.execute(new GetToolByNameQuery({ name }));
if (isDefined(existing)) {
throw new AppException(TOOL_ERRORS.NAME_ALREADY_EXISTS);
}
}
/**
* 도구 저장
*
* @param {CreateToolRequestDto} dto 생성 데이터
* @returns {Promise<ToolEntity>} 저장된 도구
*/
async saveTool(dto: CreateToolRequestDto): Promise<ToolEntity> {
return await this.commandBus.execute(new SaveToolCommand({ name: dto.name }));
}
/**
* 응답 DTO 생성
*
* @param {ToolEntity} tool 도구 엔티티
* @returns {CreateToolResponseDto} 응답 데이터
*/
buildResponseDto(tool: ToolEntity): CreateToolResponseDto {
return CreateToolResponseDto.from(tool);
}
}
interface CreateToolUseCaseProps {
bodyDto: CreateToolRequestDto;
}
execute 메서드가 유일한 public 진입점private 메서드로 분리DB 쓰기 작업. Command와 Handler를 같은 파일에 작성합니다.
// src/<feature>/application/commands/<action>.command.ts
export class SaveToolCommand extends Command<ToolEntity> {
constructor(public readonly props: SaveToolCommandProps) {
super();
}
}
@CommandHandler(SaveToolCommand)
export class SaveToolCommandHandler implements ICommandHandler<SaveToolCommand> {
constructor(private readonly prisma: PrismaService) {}
async execute(command: SaveToolCommand): Promise<ToolEntity> {
const { name } = command.props;
return await this.prisma.tool.create({ data: { name } });
}
}
interface SaveToolCommandProps {
name: string;
}
| 상황 | 반환 타입 |
|---|---|
| 생성 후 엔티티 반환 | Command<ToolEntity> |
| 수정 후 엔티티 반환 | Command<ToolEntity> |
| 삭제/상태 변경 | Command<void> |
DB 읽기 작업. Query와 Handler를 같은 파일에 작성합니다.
// src/<feature>/application/queries/<action>.query.ts
export class GetToolByIdQuery extends Query<ToolEntity | null> {
constructor(public readonly props: GetToolByIdQueryProps) {
super();
}
}
@QueryHandler(GetToolByIdQuery)
export class GetToolByIdQueryHandler implements IQueryHandler<GetToolByIdQuery> {
constructor(private readonly prisma: PrismaService) {}
async execute(query: GetToolByIdQuery): Promise<ToolEntity | null> {
const { toolId } = query.props;
return await this.prisma.tool.findUnique({ where: { id: toolId } });
}
}
interface GetToolByIdQueryProps {
toolId: string;
}
UseCase에서 타입 안전하게 Command/Query를 실행합니다.
import { TypedCommandBus, TypedQueryBus } from 'src/common/cqrs';
// 여러 Command/Query 사용 시 유니온 타입
constructor(
private readonly commandBus: TypedCommandBus<SaveToolCommand | DeleteToolCommand>,
private readonly queryBus: TypedQueryBus<GetToolByIdQuery | GetToolByNameQuery>,
) {}
// 실행
const tool = await this.commandBus.execute(new SaveToolCommand({ name }));
const found = await this.queryBus.execute(new GetToolByIdQuery({ toolId }));
// src/<feature>/<feature>.error.ts
import { HttpStatus } from '@nestjs/common';
export const TOOL_ERRORS = {
NOT_FOUND: {
statusCode: HttpStatus.NOT_FOUND,
errorCode: 'TOOL_NOT_FOUND',
message: '도구를 찾을 수 없습니다',
},
NAME_ALREADY_EXISTS: {
statusCode: HttpStatus.CONFLICT,
errorCode: 'TOOL_NAME_ALREADY_EXISTS',
message: '이미 존재하는 도구 이름입니다',
},
};
// 사용
throw new AppException(TOOL_ERRORS.NOT_FOUND);
엔드포인트 1개 = DTO 파일 1개 — Request와 Response DTO를 같은 파일에 작성합니다.
presenter/http/dto/
├── create-tool.dto.ts # CreateToolRequestDto + CreateToolResponseDto
├── get-tool.dto.ts # GetToolResponseDto (요청 DTO 없으면 응답만)
└── delete-tool.dto.ts # (응답 DTO만 있거나 없으면 파일 생략 가능)
// src/<feature>/presenter/http/dto/<action>.dto.ts
// 요청 DTO - class-validator 데코레이터
export class CreateToolRequestDto {
@ApiProperty()
@IsString()
@MaxLength(50)
name!: string;
}
// 응답 DTO - static from() 팩토리 메서드 사용
export class CreateToolResponseDto {
@ApiProperty()
id!: string;
@ApiProperty()
name!: string;
static from(data: CreateToolResponseDto): CreateToolResponseDto {
return { id: data.id, name: data.name };
}
}
// src/<feature>/<feature>.module.ts
@Module({
controllers: [ToolsHttpController],
providers: [
/** query-handlers */
GetToolByIdQueryHandler,
GetToolByNameQueryHandler,
/** command-handlers */
SaveToolCommandHandler,
DeleteToolCommandHandler,
/** use-cases */
GetToolListUseCase,
GetToolUseCase,
CreateToolUseCase,
DeleteToolUseCase,
],
})
export class ToolsModule {}
query-handlers → command-handlers → use-cases 순서로 주석 구분app.module.ts의 imports 배열에 추가다른 모듈의 데이터가 필요할 때는 해당 모듈의 Command/Query를 호출합니다.
// auth 모듈에서 users 모듈의 유저 조회 예시
import { GetOneUserByEmailQuery } from 'src/users/application/queries/get-one-user-by-email.query';
// Handler는 UsersModule에 등록, AuthModule에서 TypedQueryBus로 호출
const user = await this.queryBus.execute(new GetOneUserByEmailQuery({ email }));
단, 핸들러는 소유 모듈에만 등록합니다. NestJS CQRS 버스는 애플리케이션 전역이므로 별도 import 불필요.
새 기능 구현 시:
<feature>.error.ts 에러 코드 정의app.module.ts imports에 추가