Skip to content

Latest commit

 

History

History
1289 lines (994 loc) · 38.2 KB

File metadata and controls

1289 lines (994 loc) · 38.2 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Development Commands

Development Server

  • npm run start:dev - Start development server with NODE_ENV=development and file watching
  • npm run start - Start server normally
  • npm run start:debug - Start with debugging enabled
  • npm run start:prod - Start production server from dist/

Testing

  • npm run test - Run unit tests
  • npm run test:watch - Run tests in watch mode
  • npm run test:cov - Run tests with coverage report
  • npm run test:e2e - Run end-to-end tests

Code Quality

  • npm run lint - Run ESLint with auto-fix
  • npm run format - Format code with Prettier
  • npm run build - Build the application

Database (Prisma)

  • npm run prisma:generate - Generate Prisma client (outputs to generated/prisma/)
  • npm run prisma:push - Push schema changes to database
  • npm run prisma:migrate - Create and apply database migrations
  • All Prisma commands use development environment variables from src/config/env/.env.development

Domain Generation

  • npm run create:domain - Run automated domain creation script
  • Usage: ./scripts/create-domain.sh <domain-name> (e.g., product, auth-session)

Architecture Overview

This is a NestJS application following Clean Architecture principles with Domain-Driven Design (DDD) and Hexagonal Architecture (Port-Adapter Pattern):

Hexagonal Architecture Implementation

Core Concepts

  • Use Cases are the heart of business logic orchestration (not domain events)
  • Ports define contracts (interfaces) for external dependencies
  • Adapters implement ports to connect with external systems
  • Symbol-based Dependency Injection for loose coupling between layers

Layer Responsibilities

  1. Domain Layer (domain/)

    • Entities: Business objects with identity and lifecycle
    • Value Objects: Immutable objects defined by their attributes
    • Domain Services: Business logic that doesn't belong to entities
    • Domain Ports: Interfaces for domain-level abstractions
  2. Application Layer (application/)

    • Use Cases: Primary business logic orchestrators
    • Application Ports: Interfaces for external dependencies (repositories, external services)
    • DTOs: Data transfer objects for layer communication
    • Application Services: Coordination of use cases
  3. Infrastructure Layer (infrastructure/)

    • Adapters: Implementations of application ports
    • Repositories: Data persistence implementations
    • External Services: Third-party API integrations
    • Technical utilities and configurations
  4. Presentation Layer (presentation/)

    • Controllers: HTTP request/response handling
    • Guards: Authentication and authorization
    • Decorators: Cross-cutting concerns
    • Mappers: Data transformation between layers

Project Structure

  • src/domains/: Domain modules organized by DDD bounded contexts (auth/, user/)
    • Each domain implements full hexagonal architecture layers
    • Clear separation of concerns with defined interfaces
  • src/config/: Configuration management with environment-specific files
  • src/database/: Database module with Prisma service
  • src/shared/: Shared utilities and cross-domain code

Configuration System

  • Uses @nestjs/config with Joi validation
  • Environment files: src/config/env/.env.{NODE_ENV}
  • Validation schema: src/config/validation-schema.ts
  • Database config: src/config/database.config.ts

Database Setup

  • Prisma ORM with PostgreSQL
  • Client generated to: generated/prisma/
  • Schema: prisma/schema.prisma
  • Required env vars: DATABASE_URL, DIRECT_URL (optional)

Domain Creation

The project includes an automated script for creating new domain modules:

  • Script: scripts/create-domain.sh
  • Creates full Clean Architecture folder structure
  • Generates domain module with proper naming conventions
  • Supports kebab-case domain names (converted to PascalCase for classes)

Module Integration

  • Global ConfigModule setup in app.module.ts
  • Environment-specific configuration loading
  • Database module integration with Prisma

Development Environment

  • TypeScript with ts-jest for testing
  • ESLint + Prettier for code quality
  • Environment-based configuration loading
  • Custom Prisma client output location

Claude Code Guidelines

Sequential Thinking Requirement

  • ALWAYS use sequential thinking when answering user questions or planning development tasks
  • Break down complex problems into step-by-step thought processes
  • Document reasoning and decision-making process for transparency
  • Use the sequential thinking tool to analyze requirements, plan solutions, and verify approaches before implementation

Development Approach

DDD and Hexagonal Architecture Principles

  • Use Cases First: Always start with defining use cases as primary business logic orchestrators
  • Port-Adapter Pattern: Define ports (interfaces) before implementing adapters
  • Bounded Contexts: Each domain represents a bounded context with clear boundaries
  • Dependency Direction: Dependencies flow from outer layers to inner layers
  • Symbol-based DI: Use NestJS Symbol tokens for dependency injection to maintain loose coupling

Development Workflow

  1. Analysis Phase: Use sequential thinking to understand requirements and domain
  2. Design Phase:
    • Identify bounded contexts and domain entities
    • Define use cases and their interactions
    • Design ports for external dependencies
  3. Implementation Phase:
    • Start with Domain layer (entities, value objects)
    • Implement Application layer (use cases, ports)
    • Create Infrastructure adapters
    • Build Presentation layer (controllers, guards)
  4. Integration Phase: Wire modules with proper dependency injection
  5. Testing Phase: Unit tests for use cases, integration tests for adapters

Code Organization Standards

  • Naming Conventions:

    • Use Cases: {Action}{Entity}UseCase (e.g., CreateUserUseCase)
    • Ports: {Entity}Repository or {Service}Port (e.g., UserRepository, EmailServicePort)
    • Adapters: {Technology}{Port}Adapter (e.g., PrismaUserRepositoryAdapter)
    • DTOs: {Action}{Entity}Dto (e.g., CreateUserDto)
  • Dependency Injection Pattern:

    // Define Symbol tokens
    export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
    
    // Use in Use Cases
    constructor(
      @Inject(USER_REPOSITORY) private userRepository: UserRepository
    ) {}
    
    // Provide in modules
    providers: [
      {
        provide: USER_REPOSITORY,
        useClass: PrismaUserRepositoryAdapter,
      },
    ]
  • Layer Boundaries:

    • Domain layer imports: No external dependencies except shared utilities
    • Application layer imports: Only domain layer and its own ports
    • Infrastructure layer imports: Can import application ports and external libraries
    • Presentation layer imports: Application layer and framework-specific code

Error Handling Strategy

  • Domain Errors: Custom domain exceptions for business rule violations
  • Application Errors: Use case specific errors with proper context
  • Infrastructure Errors: Adapter-specific error handling and conversion
  • Presentation Errors: HTTP-specific error responses and status codes

Testing Strategy

  • Unit Tests: Focus on use cases and domain logic
  • Integration Tests: Test adapters and external system interactions
  • E2E Tests: Full feature workflows through presentation layer
  • Contract Tests: Verify port-adapter contracts

Domain-Driven Design Guidelines

Bounded Context Implementation

  • Context Boundaries: Each domain module represents a distinct bounded context
  • Ubiquitous Language: Use domain-specific terminology consistently within each context
  • Context Mapping: Define relationships between bounded contexts explicitly
  • Anti-Corruption Layer: Use adapters to protect domain integrity when integrating with external systems

Aggregate Design Principles

  • Aggregate Roots: Entities that serve as entry points to the aggregate
  • Consistency Boundaries: Maintain invariants within aggregate boundaries
  • Reference by ID: Aggregates reference each other by ID, not direct references
  • Small Aggregates: Keep aggregates small and focused on specific business rules

Value Objects and Entities

  • Value Objects: Immutable objects defined by their attributes (e.g., Email, Password)
  • Entities: Objects with identity and lifecycle (e.g., User, Session)
  • Domain Services: Operations that don't naturally belong to entities or value objects

DTO Development Guidelines

DTO Architecture Principles

DTOs (Data Transfer Objects) serve as the data contracts between layers, ensuring proper isolation and validation. They are owned by the Application Layer and form part of the Use Case's public interface.

Layer-based Validation Responsibilities

  1. Presentation Layer (DTOs + ValidationPipe)

    • Purpose: Validates shape, format, and type of incoming data (context-free)
    • Tools: class-validator decorators (@IsString, @IsEmail, @MaxLength)
    • Error Boundary: HTTP 400 BadRequestException for malformed requests
    • Performance: Fast, early rejection of obviously invalid data
  2. Application Layer (Use Cases)

    • Purpose: Validates business process rules and state consistency (context-dependent)
    • Examples: "User exists?", "Permission granted?", "Resource available?"
    • Dependencies: Requires repositories/services for validation
    • Error Boundary: HTTP 422/404/409 for business rule violations
  3. Domain Layer (Value Objects/Entities)

    • Purpose: Enforces domain invariants and object correctness
    • Examples: Email format rules, Username restrictions, Business constraints
    • Error Boundary: Domain-specific exceptions translated by Use Cases

DTO Implementation Patterns

Request DTOs (Input Validation)

Rule: Property-based initialization, NO constructors, comprehensive validation

import { IsString, IsNotEmpty, MaxLength } from 'class-validator';

export class UpdateUserRequestDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(100)
  public readonly name: string;

  // NO constructor - ValidationPipe handles instantiation
}

Response DTOs (Output Serialization)

Rule: Constructor-based mapping, @Expose decorators, static factory methods

import { Expose } from 'class-transformer';
import { User } from '../../domain/entities/user.entity';

export class UserResponseDto {
  @Expose()
  public readonly id: string;

  @Expose()
  public readonly email: string;

  @Expose()
  public readonly name: string | null;

  /**
   * Static factory method for Domain Entity → DTO mapping
   */
  public static fromEntity(entity: User): UserResponseDto {
    return new UserResponseDto(
      entity.id!,
      entity.email.value,
      entity.name ?? null,
    );
  }

  private constructor(id: string, email: string, name: string | null) {
    this.id = id;
    this.email = email;
    this.name = name;
  }
}

Nested DTOs (Complex Structures)

Rule: Use separate DTOs with @ValidateNested and @Type decorators

import { ValidateNested, IsString } from 'class-validator';
import { Type } from 'class-transformer';

class AddressDto {
  @IsString()
  street: string;
}

class CreateUserDto {
  @IsString()
  name: string;

  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;
}

Data Transformation Flow and Architectural Boundaries

Complete Data Transformation Pipeline

Domain Entity → (Use Case) → Response DTO → (Controller) → JSON Response
     ↑                          ↑                        ↑
 Business Object          Architecture Boundary      HTTP Response
Domain Invariants         Domain→DTO Mapping       DTO→JSON Serialization

Unique Responsibilities of Each Transformation Stage

1. Domain Entity → DTO (Use Case Responsibility)
// Use Case maintains architectural boundaries
return UserResponseDto.fromEntity(user); // Domain → Application transformation

Purpose: Transform domain knowledge into application contract Tools: Static factory methods (fromEntity)

2. DTO → JSON (Controller + Interceptor Responsibility)
@UseInterceptors(ClassSerializerInterceptor)  // DTO → JSON serialization

Purpose: Transform application contract into HTTP response Tools: @Expose, @Transform decorators

Why Both Stages Are Necessary

If using only one stage:

// ❌ Wrong: Direct Domain Entity exposure
async getProfile(): Promise<User> {  // Architectural boundary violation!
  return this.userRepository.findById(userId);
}

Problems:

  • Domain Entity exposed to Presentation Layer
  • Business logic dependent on HTTP serialization
  • Clean Architecture principles violated

Correct Two-Stage Transformation:

// ✅ Use Case: Domain → DTO
async execute(): Promise<UserResponseDto> {
  const user = await this.userRepository.findById(userId);
  return UserResponseDto.fromEntity(user);  // Stage 1: Architectural boundary
}

// ✅ Controller: DTO → JSON
@UseInterceptors(ClassSerializerInterceptor)  // Stage 2: HTTP serialization
async getProfile(): Promise<UserResponseDto> {
  return this.getUserProfileUseCase.execute(req.user.id);
}

Use Case Integration

Correct Pattern: Use Cases Return DTOs

Use Cases must return DTOs, not Domain Entities, to maintain architectural boundaries:

@Injectable()
export class GetUserProfileUseCase {
  async execute(userId: string): Promise<UserResponseDto> {
    const user = await this.userRepository.findById(userId);

    if (!user) {
      throw new NotFoundException('User not found');
    }

    // Use Case responsible for Domain → DTO mapping
    return UserResponseDto.fromEntity(user);
  }
}

Controller Integration with ClassSerializerInterceptor

@Controller('users')
export class UserController {
  @Get('profile')
  @UseInterceptors(ClassSerializerInterceptor) // Enable DTO serialization
  async getProfile(@Req() req: AuthenticatedRequest): Promise<UserResponseDto> {
    return this.getUserProfileUseCase.execute(req.user.id);
  }
}

Validation Flow Example

HTTP Request → ValidationPipe (DTO) → Use Case → Domain VO/Entity
     ↓                ↓              ↓           ↓
   형식검증        400 에러      비즈니스 규칙   도메인 불변조건
                              422/404 에러    Domain 에러

Example: Email validation layering

  1. DTO: @IsEmail() - Basic format check (performance optimization)
  2. Domain VO: new Email(value) - Business rules (e.g., corporate domain restrictions)
  3. Use Case: Catches Domain errors and translates to appropriate HTTP exceptions

Key Architectural Rules

  1. Dependency Direction: Domain Layer NEVER imports DTOs
  2. Layer Isolation: Use Cases act as the boundary, handling Domain ↔ DTO mapping
  3. Port Definition: DTOs are part of the Use Case's port contract
  4. Error Boundaries: Different validation layers produce different HTTP status codes
  5. Single Responsibility: DTOs handle data structure, not business logic

NestJS Best Practices

ValidationPipe vs ClassSerializerInterceptor Strategy

Data validation and serialization serve different purposes in NestJS and require different optimal application approaches.

ValidationPipe (Global Configuration Recommended)
// main.ts - Consistent validation for all inputs
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true, // Remove properties not defined in DTO
    forbidNonWhitelisted: true, // Throw error when undefined properties found
    transform: true, // Auto-transform payload types
  }),
);

Benefits: Consistent input validation across all endpoints, enhanced security Role: HTTP Request → DTO transformation and validation

ClassSerializerInterceptor (Individual Application Recommended)
// Individual application for performance optimization and precise control
@Controller('users')
export class UserController {
  @Get('profile')
  @UseInterceptors(ClassSerializerInterceptor) // Only where needed
  async getProfile(): Promise<UserResponseDto> {
    return this.getUserProfileUseCase.execute(userId);
  }
}

Benefits: Performance optimization, granular control, explicit intent Role: DTO → JSON response serialization and filtering

Global vs Individual Application Decision Guide

Feature Global Setup Individual Setup Recommended
ValidationPipe ✅ Consistent security ❌ Increased complexity Global
ClassSerializerInterceptor ❌ Performance overhead ✅ Optimization Individual

Core Principle: ValidationPipe globally for consistency, ClassSerializerInterceptor individually for performance

Practical Development Guidelines

Common Mistakes and Solutions

❌ Common Anti-Patterns

1. Use Case Directly Returning Domain Entity

// Incorrect: Architecture boundary violation
@Injectable()
export class GetUserUseCase {
  async execute(id: string): Promise<User> {
    // Domain leakage!
    return this.userRepository.findById(id);
  }
}

2. Using Constructor in Request DTO

// Incorrect: Conflicts with ValidationPipe
export class CreateUserDto {
  constructor(public name: string) {} // ❌ Interferes with ValidationPipe
}

3. Missing @Expose in Response DTO

// Incorrect: Field not included in JSON
export class UserResponseDto {
  public readonly id: string; // ❌ Missing @Expose
}
✅ Correct Patterns

1. Use Case Always Returns DTO

@Injectable()
export class GetUserUseCase {
  async execute(id: string): Promise<UserResponseDto> {
    const user = await this.userRepository.findById(id);
    return UserResponseDto.fromEntity(user); // ✅ Transform at boundary
  }
}

2. Request DTO is Property-Based

export class CreateUserDto {
  @IsString()
  @IsNotEmpty()
  public readonly name: string; // ✅ ValidationPipe compatible

  // No constructor
}

3. Response DTO with @Expose + Static Factory

export class UserResponseDto {
  @Expose()
  public readonly id: string; // ✅ Explicit exposure

  public static fromEntity(user: User): UserResponseDto {
    return new UserResponseDto(user.id!, user.email.value);
  }

  private constructor(id: string, email: string) {
    this.id = id;
    this.email = email;
  }
}

DTO Patterns in Tests

Request DTO Testing
// ✅ Create DTO with object literal
const createDto = { name: 'Test User' } as CreateUserDto;
const updateDto = { name: 'Updated' } as UpdateUserRequestDto;
Response DTO Testing
// ✅ Create expected response with object literal
const expectedResponse = {
  id: 'user-1',
  email: 'test@example.com',
  name: 'Test User',
} as UserResponseDto;

// ✅ Individual field validation to handle DTO structure changes
expect(result.id).toBe('user-1');
expect(result.email).toBe('test@example.com');
expect(result.name).toBe('Test User');

Debugging and Troubleshooting

ValidationPipe Error Resolution
// Problem: DTO validation failure
// Solution: Log input data for verification
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    // Enable only in development
    disableErrorMessages: process.env.NODE_ENV === 'production',
  }),
);
ClassSerializerInterceptor Debugging
// Problem: Field not included in response
// Solution 1: Check @Expose() decorator
@Expose()
public readonly fieldName: string;

// Solution 2: Verify interceptor application
@UseInterceptors(ClassSerializerInterceptor)
async getEndpoint(): Promise<ResponseDto> { }

Performance Optimization Strategy

Performance Benefits of Individual ClassSerializerInterceptor Application

1. Memory Efficiency
// ❌ Global application: Serialization overhead on all responses
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

// ✅ Individual application: Serialization only where needed
@UseInterceptors(ClassSerializerInterceptor)  // Selective application

Benefits: Prevents unnecessary object conversion and memory allocation

2. CPU Usage Optimization
// Simple responses that don't need serialization
@Get('health')
async healthCheck(): string {  // No serialization needed
  return 'OK';
}

// Apply serialization only to complex objects
@Get('profile')
@UseInterceptors(ClassSerializerInterceptor)  // Only where needed
async getProfile(): Promise<UserResponseDto> {
  return this.getUserProfileUseCase.execute(userId);
}

DTO Design Optimization Principles

1. Minimal Data Exposure
// ✅ Expose only necessary fields
export class UserSummaryDto {
  @Expose()
  id: string;

  @Expose()
  name: string;

  // Exclude unnecessary fields like email, createdAt
}

// ✅ Separate DTOs by context
export class UserDetailDto extends UserSummaryDto {
  @Expose()
  email: string;

  @Expose()
  createdAt: Date;
}
2. Reusable DTO Structure
// ✅ Base DTO
export class BaseUserDto {
  @Expose()
  id: string;

  @Expose()
  name: string;
}

// ✅ Extended DTO
export class UserWithEmailDto extends BaseUserDto {
  @Expose()
  email: string;
}
3. Nested Structure Performance Optimization
// ✅ Efficient transformation with @Type() decorator
export class PostResponseDto {
  @Expose()
  id: string;

  @Expose()
  title: string;

  @Expose()
  @Type(() => UserSummaryDto) // Nested object optimization
  author: UserSummaryDto;
}

Large Data Processing Strategy

Pagination and DTOs
export class PaginatedResponseDto<T> {
  @Expose()
  data: T[];

  @Expose()
  pagination: {
    page: number;
    limit: number;
    total: number;
  };

  // ✅ Efficient creation with static factory
  public static create<T>(
    data: T[],
    page: number,
    limit: number,
    total: number,
  ): PaginatedResponseDto<T> {
    return Object.assign(new PaginatedResponseDto<T>(), {
      data,
      pagination: { page, limit, total },
    });
  }
}
Lazy Loading Pattern
// ✅ Load related data only when needed
export class PostDetailDto {
  @Expose()
  id: string;

  @Expose()
  title: string;

  // Comments loaded from separate endpoint
  // Excludes comments to improve initial response speed
}

Caching with DTO Utilization

Response DTO Caching Strategy
@Controller('users')
export class UserController {
  @Get(':id/profile')
  @UseInterceptors(ClassSerializerInterceptor)
  @UseInterceptors(CacheInterceptor) // DTO-based caching
  async getProfile(@Param('id') id: string): Promise<UserResponseDto> {
    return this.getUserProfileUseCase.execute(id);
  }
}

Benefits: Serialized DTO structure provides consistency for cache key generation

Common Anti-Patterns to Avoid

Domain entities returned by Use CasesBusiness validation in DTOsConstructors in Request DTOsMissing @Expose decorators in Response DTOsDirect Domain Entity exposure in controllersGlobal ClassSerializerInterceptor usage

Implementation Examples

Use Case Implementation Pattern

// Application layer - Use Case
@Injectable()
export class AuthenticateUserUseCase {
  constructor(
    @Inject(USER_REPOSITORY) private userRepository: UserRepository,
    @Inject(JWT_SERVICE) private jwtService: JwtServicePort,
  ) {}

  async execute(dto: AuthenticateUserDto): Promise<AuthenticationResult> {
    // 1. Validate input
    // 2. Load domain entity
    // 3. Apply business logic
    // 4. Persist changes
    // 5. Return result
  }
}

Port and Adapter Pattern

// Application layer - Port
export interface UserRepository {
  findByEmail(email: Email): Promise<User | null>;
  save(user: User): Promise<void>;
}

// Infrastructure layer - Adapter
@Injectable()
export class PrismaUserRepositoryAdapter implements UserRepository {
  constructor(private prisma: PrismaService) {}

  async findByEmail(email: Email): Promise<User | null> {
    // Prisma-specific implementation
  }
}

CQRS (Command Query Responsibility Segregation) Guidelines

CQRS Pattern Implementation

이 프로젝트는 CQRS 패턴을 적용하여 읽기(Query)와 쓰기(Command) 작업을 명확히 분리합니다. 이를 통해 성능 최적화, 확장성, 그리고 복잡성 관리를 개선합니다.

Core Principles

1. Command-Query Separation

  • Commands: 시스템 상태를 변경하는 작업 (Create, Update, Delete)
  • Queries: 데이터를 조회하는 작업 (Read), 시스템 상태 변경 없음
  • Segregation: Command와 Query는 서로 다른 모델과 최적화 전략 사용

2. Responsibility Segregation

  • Command Side: 비즈니스 로직, 검증, 상태 변경에 최적화
  • Query Side: 읽기 성능, 데이터 변환, 뷰 모델에 최적화
  • Independent Scaling: 읽기와 쓰기 워크로드를 독립적으로 확장

Architecture Pattern

┌─────────────── COMMAND SIDE ───────────────┐  ┌─────────────── QUERY SIDE ──────────────┐
│                                            │  │                                         │
│  Controller → UseCase → Domain → WriteRepo  │  │  Controller → QueryService → ReadRepo   │
│                                            │  │                                         │
│  ✅ State Changes                          │  │  ✅ Data Retrieval                     │
│  ✅ Business Logic                         │  │  ✅ Performance Optimization            │
│  ✅ Validation                             │  │  ✅ View Models                        │
│                                            │  │                                         │
└────────────────┬───────────────────────────┘  └─────────────────┬───────────────────────┘
                 │                                                 │
                 └─────────────────── DATABASE ──────────────────┘

Implementation Guidelines

1. Repository Segregation

❌ Current Mixed Approach:

// Mixing Query and Command in single interface
export interface IUserRepository {
  // Queries
  findByEmail(email: string): Promise<User | null>;
  findById(id: string): Promise<User | null>;

  // Commands
  save(user: User): Promise<User>;
  delete(id: string): Promise<void>;
}

✅ Recommended CQRS Approach:

// Separate Query Repository
export interface IUserQueryRepository {
  findByEmail(email: string): Promise<UserReadModel | null>;
  findById(id: string): Promise<UserReadModel | null>;
  findByProvider(
    provider: string,
    providerId: string,
  ): Promise<UserReadModel | null>;
  searchUsers(criteria: UserSearchCriteria): Promise<UserReadModel[]>;
}

// Separate Command Repository
export interface IUserCommandRepository {
  save(user: User): Promise<void>; // Handles both create and update operations
  delete(id: string): Promise<void>;
}

2. Use Case Segregation

Command Use Cases (Write Operations):

@Injectable()
export class CreateUserCommand {
  constructor(
    @Inject(USER_COMMAND_REPOSITORY)
    private commandRepo: IUserCommandRepository,
  ) {}

  async execute(input: CreateUserInput): Promise<void> {
    const user = User.fromInput(input);
    await this.commandRepo.save(user);
    // Emit domain events if needed
  }
}

Query Use Cases (Read Operations):

@Injectable()
export class GetUserProfileQuery {
  constructor(
    @Inject(USER_QUERY_REPOSITORY)
    private queryRepo: IUserQueryRepository,
  ) {}

  async execute(userId: string): Promise<UserProfileDto> {
    const user = await this.queryRepo.findById(userId);
    return UserProfileDto.fromReadModel(user);
  }
}

3. Adapter Implementation

Command Adapter (Write-Optimized):

@Injectable()
export class PrismaUserCommandRepository implements IUserCommandRepository {
  constructor(private prisma: PrismaService) {}

  async save(user: User): Promise<void> {
    const data = user.toPrimitives();
    await this.prisma.user.upsert({
      where: { id: data.id },
      update: data,
      create: data,
    });
  }
}

Query Adapter (Read-Optimized):

@Injectable()
export class PrismaUserQueryRepository implements IUserQueryRepository {
  constructor(private prisma: PrismaService) {}

  async findById(id: string): Promise<UserReadModel | null> {
    const user = await this.prisma.user.findUnique({
      where: { id },
      include: { profile: true }, // Optimized joins for read
    });
    return user ? UserReadModel.fromPrisma(user) : null;
  }
}

4. Read Model Optimization

Specialized Read Models:

// Optimized for display purposes
export class UserProfileReadModel {
  constructor(
    public readonly id: string,
    public readonly displayName: string,
    public readonly avatar: string,
    public readonly lastActive: Date,
    public readonly preferences: UserPreferences,
  ) {}
}

// Optimized for list views
export class UserSummaryReadModel {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string,
    public readonly status: UserStatus,
  ) {}
}

Module Organization

Domain Structure for CQRS

src/domains/{domain}/
├── application/
│   ├── commands/          # Command Use Cases
│   │   ├── create-{entity}.command.ts
│   │   ├── update-{entity}.command.ts
│   │   └── delete-{entity}.command.ts
│   ├── queries/           # Query Use Cases
│   │   ├── get-{entity}.query.ts
│   │   ├── list-{entities}.query.ts
│   │   └── search-{entities}.query.ts
│   ├── ports/
│   │   ├── {entity}-command.repository.ts
│   │   └── {entity}-query.repository.ts
│   └── dto/
│       ├── commands/      # Command DTOs
│       └── queries/       # Query DTOs & Read Models
├── infrastructure/
│   └── adapters/
│       ├── {tech}-{entity}-command.repository.ts
│       └── {tech}-{entity}-query.repository.ts

Performance Benefits

1. Independent Scaling

// Different connection pools for read/write
@Module({
  providers: [
    {
      provide: 'WRITE_DB_CONNECTION',
      useFactory: () => createConnection(writeDbConfig),
    },
    {
      provide: 'READ_DB_CONNECTION',
      useFactory: () => createConnection(readDbConfig),
    },
  ],
})

2. Query Optimization

// Read-optimized queries with materialized views
@Injectable()
export class OptimizedUserQueryRepository {
  async getUserDashboard(userId: string): Promise<DashboardReadModel> {
    // Use pre-computed materialized view
    const result = await this.prisma.$queryRaw`
      SELECT * FROM user_dashboard_view WHERE user_id = ${userId}
    `;
    return DashboardReadModel.fromRaw(result);
  }
}

3. Caching Strategies

@Injectable()
export class CachedUserQueryRepository implements IUserQueryRepository {
  constructor(
    private baseRepo: IUserQueryRepository,
    @Inject('REDIS_CLIENT') private redis: Redis,
  ) {}

  async findById(id: string): Promise<UserReadModel | null> {
    // Check cache first for queries
    const cached = await this.redis.get(`user:${id}`);
    if (cached) return JSON.parse(cached);

    const user = await this.baseRepo.findById(id);
    if (user) {
      await this.redis.setex(`user:${id}`, 300, JSON.stringify(user));
    }
    return user;
  }
}

Migration Strategy

Phase 1: Use Case Segregation

  1. Create separate Command and Query Use Cases
  2. Keep existing Repository interfaces temporarily
  3. Update Controllers to use appropriate Use Cases

Phase 2: Repository Segregation

  1. Define separate Command and Query Repository interfaces
  2. Implement segregated Adapters
  3. Update Use Cases to use appropriate Repositories

Phase 3: Optimization

  1. Add Read Model specialization
  2. Implement caching for Query side
  3. Add performance monitoring

Testing Strategy

Command Testing (Write Side)

describe('CreateUserCommand', () => {
  it('should save user through command repository', async () => {
    // Focus on business logic and state changes
    const mockCommandRepo = createMock<IUserCommandRepository>();
    const command = new CreateUserCommand(mockCommandRepo);

    await command.execute(validInput);

    expect(mockCommandRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({ email: validInput.email }),
    );
  });
});

Query Testing (Read Side)

describe('GetUserProfileQuery', () => {
  it('should return formatted user profile', async () => {
    // Focus on data transformation and view models
    const mockQueryRepo = createMock<IUserQueryRepository>();
    mockQueryRepo.findById.mockResolvedValue(mockUserReadModel);

    const query = new GetUserProfileQuery(mockQueryRepo);
    const result = await query.execute(userId);

    expect(result).toEqual(expectedProfileDto);
  });
});

Common Patterns

1. Event Sourcing Integration

@Injectable()
export class EventSourcedUserCommandRepository {
  async save(user: User): Promise<void> {
    const events = user.getUncommittedEvents();
    await this.eventStore.saveEvents(user.id, events);

    // Update read model asynchronously
    this.eventBus.publishAll(events);
  }
}

2. Eventual Consistency

@EventHandler(UserCreatedEvent)
export class UpdateUserReadModelHandler {
  constructor(
    @Inject(USER_QUERY_REPOSITORY_TOKEN)
    private queryRepo: IUserQueryRepository,
  ) {}

  async handle(event: UserCreatedEvent): Promise<void> {
    await this.queryRepo.updateReadModel(event.userId, event.userData);
  }
}

Repository Type-Safety Guidelines (Prisma)

Goals

  • Repository(Infra) 레이어에서 Prisma Client를 사용할 때, 생성 타입을 최대한 활용해 any 없이 컴파일 타임 안전성을 확보합니다.
  • 도메인/애플리케이션 레이어로 Prisma 타입이 새지 않도록 레이어 경계를 엄격히 지킵니다.

Rules

  1. Prisma 생성 타입 직접 사용

    • where, orderBy 등 쿼리 인자는 반드시 생성 타입 사용:
      • Prisma.EnergySessionWhereInput, Prisma.EnergySessionOrderByWithRelationInput
      • 날짜: DateTimeFilter/DateTimeNullableFilter, 정수/실수: IntFilter/FloatFilter
      • 리스트: StringNullableListFilterhasSome/hasEvery/isEmpty
    • 예시:
      const where: Prisma.EnergySessionWhereInput = { userId };
      where.tags = matchAll ? { hasEvery: tags } : { hasSome: tags };
  2. 타입 별칭은 인프라/DB 영역에만 배치

    • src/database/prisma.types.ts에 별칭을 정의하고(선택), 인프라 어댑터에서만 import:
      export type EnergySessionRow = EnergySession; // from generated client
      export type EnergySessionWhere = Prisma.EnergySessionWhereInput;
      export type EnergySessionOrderBy =
        Prisma.EnergySessionOrderByWithRelationInput;
    • 도메인/애플리케이션 레이어에서는 Prisma 타입을 직접 import하지 않습니다.
  3. 빌더 함수는 구체 타입 반환

    • buildWhereClause(): Prisma.EnergySessionWhereInput
    • buildOrderByClause(): Prisma.EnergySessionOrderByWithRelationInput
    • 동적 키 매핑은 안전한 맵으로 제한:
      const map = {
        sessionStartTime: { sessionStartTime: direction },
        createdAt: { createdAt: direction },
        energyScore: { energyScore: direction },
        calculatedProductivity: { calculatedProductivity: direction },
      } as const;
      return map[field];
  4. 로우 타입 드리프트 금지

    • Prisma Row를 커스텀 인터페이스로 재정의하지 말고, 생성된 모델 타입 사용:
      import type { EnergySession } from '../../generated/prisma';
      private toReadModel(row: EnergySession): EnergySessionReadModel { /* ... */ }
  5. any/ESLint Disable 금지

    • any 금지, 파일 상단 eslint-disable 금지.
    • 불가피한 동적 조합은 생성 필터 객체를 별도로 만들어 할당:
      const energyScoreFilter: Prisma.IntFilter<'EnergySession'> = {};
      if (min !== undefined) energyScoreFilter.gte = min;
      if (max !== undefined) energyScoreFilter.lte = max;
      where.energyScore = energyScoreFilter;
  6. 컨트롤러에서의 필터 빌더

    • 컨트롤러는 DTO/쿼리 파라미터를 GetEnergySessionsFilters로 정제하여 유스케이스에 전달.
    • 반환 타입을 GetEnergySessionsFilters | undefined로 명시하고 Partial<>로 조립 후 단언 처리.
  7. 커맨드 리포지토리의 입력 제한

    • 도메인 → Prisma 매핑은 생성 입력 타입으로 제한:
      private toPrismaData(...): Pick<
        Prisma.EnergySessionUncheckedCreateInput,
        'id'|'userId'|'energyScore'|'duration'|'difficultyModifier'|
        'activity'|'notes'|'location'|'tags'|'calculatedProductivity'|
        'sessionStartTime'|'sessionEndTime'|'createdAt'|'updatedAt'
      >
  8. 테스트/검증

    • 린트 경고 0 유지(npm run lint).
    • e2e로 날짜/점수/태그/활동/장소/활성 조합 스모크.

Notes

  • Date는 문자열 대신 Date 객체로 전달(문서 FAQ 참조).
  • 필요 시 Prisma.validator<...>()({...})로 정적 타입 검증 강화.