This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
npm run start:dev- Start development server with NODE_ENV=development and file watchingnpm run start- Start server normallynpm run start:debug- Start with debugging enablednpm run start:prod- Start production server from dist/
npm run test- Run unit testsnpm run test:watch- Run tests in watch modenpm run test:cov- Run tests with coverage reportnpm run test:e2e- Run end-to-end tests
npm run lint- Run ESLint with auto-fixnpm run format- Format code with Prettiernpm run build- Build the application
npm run prisma:generate- Generate Prisma client (outputs to generated/prisma/)npm run prisma:push- Push schema changes to databasenpm run prisma:migrate- Create and apply database migrations- All Prisma commands use development environment variables from src/config/env/.env.development
npm run create:domain- Run automated domain creation script- Usage:
./scripts/create-domain.sh <domain-name>(e.g., product, auth-session)
This is a NestJS application following Clean Architecture principles with Domain-Driven Design (DDD) and Hexagonal Architecture (Port-Adapter Pattern):
- 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
-
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
-
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
-
Infrastructure Layer (
infrastructure/)- Adapters: Implementations of application ports
- Repositories: Data persistence implementations
- External Services: Third-party API integrations
- Technical utilities and configurations
-
Presentation Layer (
presentation/)- Controllers: HTTP request/response handling
- Guards: Authentication and authorization
- Decorators: Cross-cutting concerns
- Mappers: Data transformation between layers
- 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
- 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
- Prisma ORM with PostgreSQL
- Client generated to:
generated/prisma/ - Schema:
prisma/schema.prisma - Required env vars: DATABASE_URL, DIRECT_URL (optional)
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)
- Global ConfigModule setup in app.module.ts
- Environment-specific configuration loading
- Database module integration with Prisma
- TypeScript with ts-jest for testing
- ESLint + Prettier for code quality
- Environment-based configuration loading
- Custom Prisma client output location
- 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
- 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
- Analysis Phase: Use sequential thinking to understand requirements and domain
- Design Phase:
- Identify bounded contexts and domain entities
- Define use cases and their interactions
- Design ports for external dependencies
- Implementation Phase:
- Start with Domain layer (entities, value objects)
- Implement Application layer (use cases, ports)
- Create Infrastructure adapters
- Build Presentation layer (controllers, guards)
- Integration Phase: Wire modules with proper dependency injection
- Testing Phase: Unit tests for use cases, integration tests for adapters
-
Naming Conventions:
- Use Cases:
{Action}{Entity}UseCase(e.g.,CreateUserUseCase) - Ports:
{Entity}Repositoryor{Service}Port(e.g.,UserRepository,EmailServicePort) - Adapters:
{Technology}{Port}Adapter(e.g.,PrismaUserRepositoryAdapter) - DTOs:
{Action}{Entity}Dto(e.g.,CreateUserDto)
- Use Cases:
-
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
- 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
- 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
- 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 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: 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
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.
-
Presentation Layer (DTOs + ValidationPipe)
- Purpose: Validates shape, format, and type of incoming data (context-free)
- Tools:
class-validatordecorators (@IsString,@IsEmail,@MaxLength) - Error Boundary: HTTP 400 BadRequestException for malformed requests
- Performance: Fast, early rejection of obviously invalid data
-
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
-
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
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
}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;
}
}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;
}Domain Entity → (Use Case) → Response DTO → (Controller) → JSON Response
↑ ↑ ↑
Business Object Architecture Boundary HTTP Response
Domain Invariants Domain→DTO Mapping DTO→JSON Serialization
// Use Case maintains architectural boundaries
return UserResponseDto.fromEntity(user); // Domain → Application transformationPurpose: Transform domain knowledge into application contract
Tools: Static factory methods (fromEntity)
@UseInterceptors(ClassSerializerInterceptor) // DTO → JSON serializationPurpose: Transform application contract into HTTP response
Tools: @Expose, @Transform decorators
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 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('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);
}
}HTTP Request → ValidationPipe (DTO) → Use Case → Domain VO/Entity
↓ ↓ ↓ ↓
형식검증 400 에러 비즈니스 규칙 도메인 불변조건
422/404 에러 Domain 에러
Example: Email validation layering
- DTO:
@IsEmail()- Basic format check (performance optimization) - Domain VO:
new Email(value)- Business rules (e.g., corporate domain restrictions) - Use Case: Catches Domain errors and translates to appropriate HTTP exceptions
- Dependency Direction: Domain Layer NEVER imports DTOs
- Layer Isolation: Use Cases act as the boundary, handling Domain ↔ DTO mapping
- Port Definition: DTOs are part of the Use Case's port contract
- Error Boundaries: Different validation layers produce different HTTP status codes
- Single Responsibility: DTOs handle data structure, not business logic
Data validation and serialization serve different purposes in NestJS and require different optimal application approaches.
// 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
// 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
| 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
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
}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;
}
}// ✅ Create DTO with object literal
const createDto = { name: 'Test User' } as CreateUserDto;
const updateDto = { name: 'Updated' } as UpdateUserRequestDto;// ✅ 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');// 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',
}),
);// 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> { }// ❌ Global application: Serialization overhead on all responses
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
// ✅ Individual application: Serialization only where needed
@UseInterceptors(ClassSerializerInterceptor) // Selective applicationBenefits: Prevents unnecessary object conversion and memory allocation
// 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);
}// ✅ 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;
}// ✅ Base DTO
export class BaseUserDto {
@Expose()
id: string;
@Expose()
name: string;
}
// ✅ Extended DTO
export class UserWithEmailDto extends BaseUserDto {
@Expose()
email: string;
}// ✅ Efficient transformation with @Type() decorator
export class PostResponseDto {
@Expose()
id: string;
@Expose()
title: string;
@Expose()
@Type(() => UserSummaryDto) // Nested object optimization
author: UserSummaryDto;
}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 },
});
}
}// ✅ 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
}@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
❌ Domain entities returned by Use Cases ❌ Business validation in DTOs ❌ Constructors in Request DTOs ❌ Missing @Expose decorators in Response DTOs ❌ Direct Domain Entity exposure in controllers ❌ Global ClassSerializerInterceptor usage
// 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
}
}// 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 패턴을 적용하여 읽기(Query)와 쓰기(Command) 작업을 명확히 분리합니다. 이를 통해 성능 최적화, 확장성, 그리고 복잡성 관리를 개선합니다.
1. Command-Query Separation
- Commands: 시스템 상태를 변경하는 작업 (Create, Update, Delete)
- Queries: 데이터를 조회하는 작업 (Read), 시스템 상태 변경 없음
- Segregation: Command와 Query는 서로 다른 모델과 최적화 전략 사용
2. Responsibility Segregation
- Command Side: 비즈니스 로직, 검증, 상태 변경에 최적화
- Query Side: 읽기 성능, 데이터 변환, 뷰 모델에 최적화
- Independent Scaling: 읽기와 쓰기 워크로드를 독립적으로 확장
┌─────────────── COMMAND SIDE ───────────────┐ ┌─────────────── QUERY SIDE ──────────────┐
│ │ │ │
│ Controller → UseCase → Domain → WriteRepo │ │ Controller → QueryService → ReadRepo │
│ │ │ │
│ ✅ State Changes │ │ ✅ Data Retrieval │
│ ✅ Business Logic │ │ ✅ Performance Optimization │
│ ✅ Validation │ │ ✅ View Models │
│ │ │ │
└────────────────┬───────────────────────────┘ └─────────────────┬───────────────────────┘
│ │
└─────────────────── DATABASE ──────────────────┘
❌ 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>;
}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);
}
}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;
}
}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,
) {}
}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
// Different connection pools for read/write
@Module({
providers: [
{
provide: 'WRITE_DB_CONNECTION',
useFactory: () => createConnection(writeDbConfig),
},
{
provide: 'READ_DB_CONNECTION',
useFactory: () => createConnection(readDbConfig),
},
],
})// 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);
}
}@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;
}
}- Create separate Command and Query Use Cases
- Keep existing Repository interfaces temporarily
- Update Controllers to use appropriate Use Cases
- Define separate Command and Query Repository interfaces
- Implement segregated Adapters
- Update Use Cases to use appropriate Repositories
- Add Read Model specialization
- Implement caching for Query side
- Add performance monitoring
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 }),
);
});
});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);
});
});@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);
}
}@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(Infra) 레이어에서 Prisma Client를 사용할 때, 생성 타입을 최대한 활용해
any없이 컴파일 타임 안전성을 확보합니다. - 도메인/애플리케이션 레이어로 Prisma 타입이 새지 않도록 레이어 경계를 엄격히 지킵니다.
-
Prisma 생성 타입 직접 사용
where,orderBy등 쿼리 인자는 반드시 생성 타입 사용:Prisma.EnergySessionWhereInput,Prisma.EnergySessionOrderByWithRelationInput- 날짜:
DateTimeFilter/DateTimeNullableFilter, 정수/실수:IntFilter/FloatFilter - 리스트:
StringNullableListFilter의hasSome/hasEvery/isEmpty
- 예시:
const where: Prisma.EnergySessionWhereInput = { userId }; where.tags = matchAll ? { hasEvery: tags } : { hasSome: tags };
-
타입 별칭은 인프라/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하지 않습니다.
-
빌더 함수는 구체 타입 반환
buildWhereClause(): Prisma.EnergySessionWhereInputbuildOrderByClause(): Prisma.EnergySessionOrderByWithRelationInput- 동적 키 매핑은 안전한 맵으로 제한:
const map = { sessionStartTime: { sessionStartTime: direction }, createdAt: { createdAt: direction }, energyScore: { energyScore: direction }, calculatedProductivity: { calculatedProductivity: direction }, } as const; return map[field];
-
로우 타입 드리프트 금지
- Prisma Row를 커스텀 인터페이스로 재정의하지 말고, 생성된 모델 타입 사용:
import type { EnergySession } from '../../generated/prisma'; private toReadModel(row: EnergySession): EnergySessionReadModel { /* ... */ }
- Prisma Row를 커스텀 인터페이스로 재정의하지 말고, 생성된 모델 타입 사용:
-
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;
-
컨트롤러에서의 필터 빌더
- 컨트롤러는 DTO/쿼리 파라미터를
GetEnergySessionsFilters로 정제하여 유스케이스에 전달. - 반환 타입을
GetEnergySessionsFilters | undefined로 명시하고Partial<>로 조립 후 단언 처리.
- 컨트롤러는 DTO/쿼리 파라미터를
-
커맨드 리포지토리의 입력 제한
- 도메인 → Prisma 매핑은 생성 입력 타입으로 제한:
private toPrismaData(...): Pick< Prisma.EnergySessionUncheckedCreateInput, 'id'|'userId'|'energyScore'|'duration'|'difficultyModifier'| 'activity'|'notes'|'location'|'tags'|'calculatedProductivity'| 'sessionStartTime'|'sessionEndTime'|'createdAt'|'updatedAt' >
- 도메인 → Prisma 매핑은 생성 입력 타입으로 제한:
-
테스트/검증
- 린트 경고 0 유지(
npm run lint). - e2e로 날짜/점수/태그/활동/장소/활성 조합 스모크.
- 린트 경고 0 유지(
- Date는 문자열 대신
Date객체로 전달(문서 FAQ 참조). - 필요 시
Prisma.validator<...>()({...})로 정적 타입 검증 강화.