diff --git a/.agents/rules/api-design.md b/.agents/rules/api-design.md new file mode 100644 index 00000000..bbefc763 --- /dev/null +++ b/.agents/rules/api-design.md @@ -0,0 +1,221 @@ +# API Design Rules + +## Controller Structure + +```kotlin +@Tag(name = "USER", description = "사용자 API") +@RestController +@RequestMapping("/api/v1/users") +@ApiErrorCodeExample(UserErrorCode::class, JwtErrorCode::class) +class UserController( + private val userUsecase: UserUsecase +) { + @GetMapping + @Operation(summary = "내 정보 조회") + fun getUser(@Parameter(hidden = true) @CurrentUser userId: Long): CommonResponse = + CommonResponse.success(USER_FIND_BY_ID_SUCCESS, userUsecase.find(userId)) +} +``` + +## Club-scoped API + +Club resources use `/api/v4/clubs/{clubId}/...`. `clubId` is Base62 TSID — use two annotations together: + +```kotlin +@TsidParam // Swagger (type: string) +@TsidPathVariable clubId: Long // decodes Base62 → Long at runtime +``` + +## Required Annotations + +| Annotation | Purpose | +|-----------|---------| +| `@Tag(name = "DOMAIN")` | OpenAPI grouping | +| `@Operation(summary = "...")` | API description | +| `@Parameter(hidden = true)` | Hide internal params from docs | +| `@Valid` | Enable validation | +| `@ApiErrorCodeExample(...)` | Auto-register error examples in Swagger | + +## Response Format + +Wrap responses in `CommonResponse`: + +```kotlin +data class CommonResponse( + val code: Int, + val message: String, + val data: T?, +) { + companion object { + @JvmStatic + fun success(responseCode: ResponseCodeInterface): CommonResponse = + CommonResponse(code = responseCode.code, message = responseCode.message, data = null) + + @JvmStatic + fun success(responseCode: ResponseCodeInterface, data: T): CommonResponse = + CommonResponse(code = responseCode.code, message = responseCode.message, data = data) + + @JvmStatic + fun error(errorCode: ErrorCodeInterface): CommonResponse = + CommonResponse(code = errorCode.code, message = errorCode.message, data = null) + } +} +``` + +## Response Codes + +```kotlin +enum class UserResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String +) : ResponseCodeInterface { + USER_FIND_ALL_SUCCESS(10900, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), + USER_FIND_BY_ID_SUCCESS(10907, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), + USER_UPDATE_SUCCESS(10908, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), +} +``` + +- Success code enums must implement `ResponseCodeInterface`. +- Controllers should return success responses with enum directly: + - `CommonResponse.success(USER_FIND_BY_ID_SUCCESS, data)` + - `CommonResponse.success(USER_UPDATE_SUCCESS)` + +## Code Format + +| | Mean | Value | +|---|-----------------|----------------------------------------------------------------------------| +| X | Category | 1=Success, 2=Domain Error, 3=Infra/Server Error, 4=Client/Validation Error | +| DD | Domain ID | 01~99 | +| NN | In Domain Count | 00~99 | + +## Domain ID + +| DD | Domain | Success Range | Domain Error Range | Infra Error Range | +|----|------------|---------------|--------------------|-------------------| +| 01 | account | 10100~ | 20100~ | — | +| 02 | attendance | 10200~ | 20200~ | — | +| 03 | session | 10300~ | 20300~ | — | +| 04 | board | 10400~ | 20400~ | — | +| 05 | comment | 10500~ | 20500~ | — | +| 06 | file | 10600~ | 20600~ | 30600~ | +| 07 | penalty | 10700~ | 20700~ | — | +| 08 | schedule | 10800~ | 20800~ | — | +| 09 | user | 10900~ | 20900~ | — | +| 10 | cardinal | 11000~ | 21000~ | — | +| 11 | club | 11100~ | 21100~ | — | +| 12 | dashboard | 11200~ | 21200~ | — | +| 13 | university | 11300~ | — | 31300~ | +| 90 | jwt/auth | — | 29000~ | — | +| 99 | common | — | — | 39900~ | + +## Domain Success Codes + +| Domain | ResponseCode Enum | Code Range | Location | +|--------|------------------|------------|----------| +| Account | `AccountResponseCode` | `101xx` | `domain/account/presentation/` | +| Attendance | `AttendanceResponseCode` | `102xx` | `domain/attendance/presentation/` | +| Session | `SessionResponseCode` | `103xx` | `domain/session/presentation/` | +| Board | `BoardResponseCode` | `104xx` | `domain/board/presentation/` | +| Comment | `CommentResponseCode` | `105xx` | `domain/comment/presentation/` | +| File | `FileResponseCode` | `106xx` | `domain/file/presentation/` | +| Penalty | `PenaltyResponseCode` | `107xx` | `domain/penalty/presentation/` | +| Schedule | `ScheduleResponseCode` | `108xx` | `domain/schedule/presentation/` | +| User | `UserResponseCode` | `109xx` | `domain/user/presentation/` | +| Cardinal | `CardinalResponseCode` | `110xx` | `domain/cardinal/presentation/` | +| Club | `ClubResponseCode` | `111xx` | `domain/club/presentation/` | +| Dashboard | `DashboardResponseCode` | `112xx` | `domain/dashboard/presentation/` | +| University | `UniversityResponseCode` | `113xx` | `domain/university/presentation/` | + +## Domain Error Codes + +| Domain | ErrorCode Enum | Code Range | Location | +|--------|---------------|------------|----------| +| Account | `AccountErrorCode` | `201xx` | `domain/account/application/exception/` | +| Attendance | `AttendanceErrorCode` | `202xx` | `domain/attendance/application/exception/` | +| Session | `SessionErrorCode` | `203xx` | `domain/session/application/exception/` | +| Board | `BoardErrorCode` | `204xx` | `domain/board/application/exception/` | +| Comment | `CommentErrorCode` | `205xx` | `domain/comment/application/exception/` | +| File | `FileErrorCode` | `206xx` (domain), `306xx` (infra) | `domain/file/application/exception/` | +| Penalty | `PenaltyErrorCode` | `207xx` | `domain/penalty/application/exception/` | +| Schedule | `EventErrorCode` | `208xx` | `domain/schedule/application/exception/` | +| User | `UserErrorCode` | `209xx` | `domain/user/application/exception/` | +| Cardinal | `CardinalErrorCode` | `210xx` | `domain/cardinal/application/exception/` | +| Club | `ClubErrorCode` | `211xx` | `domain/club/application/exception/` | +| Dashboard | `DashboardErrorCode` | `212xx` | `domain/dashboard/application/exception/` | +| University | `UniversityErrorCode` | `313xx` (infra) | `domain/university/application/exception/` | +| JWT (Global) | `JwtErrorCode` | `290xx` | `global/auth/jwt/application/exception/` | + +## HTTP Methods + +| Method | Usage | +|--------|-------| +| GET | Read operations, no body | +| POST | Create resources | +| PUT | Full updates | +| PATCH | Partial updates | +| DELETE | Remove resources | + +## Path Design + +``` +GET /users # List users +GET /users/{userId} # Get single user +POST /users # Create user +PATCH /users/{userId} # Update user +DELETE /users/{userId} # Delete user +POST /users/{userId}/activate # Action on resource +``` + +### Admin Endpoints + +`admin` prefix comes **before** `clubs/{clubId}`: `/api/v4/admin/clubs/{clubId}/{resource}` + +``` +/api/v4/clubs/{clubId}/boards # user-facing +/api/v4/admin/clubs/{clubId}/boards # admin +``` + +Enables a single SecurityConfig rule: `.requestMatchers("/api/v4/admin/**").hasRole("ADMIN")` + +## Query & Path Parameters + +- Query params for filtering: `?page=0&size=10&status=ACTIVE` +- Path variables for resource identification: `/users/{userId}` + +## Request/Response DTO + +```kotlin +// Request +data class CreateUserRequest( + @field:Schema(description = "User name", example = "John Doe") + @field:NotBlank + @field:Size(max = 100) + val name: String, + + @field:Schema(description = "Email address", example = "john@example.com") + @field:NotBlank + @field:Email + val email: String +) + +// Response +data class UserResponse( + @Schema(description = "User ID", example = "1") + val id: Long, + + @Schema(description = "User name", example = "John Doe") + val name: String, + + @Schema(description = "Email address", example = "john@example.com") + val email: String? +) +``` + +## Validation + +Use Jakarta validation annotations in DTOs: +- `@NotNull`, `@NotEmpty`, `@NotBlank` +- `@Size(min = 1, max = 100)` +- `@Positive`, `@PositiveOrZero` +- `@Email`, `@Pattern` diff --git a/.agents/rules/architecture.md b/.agents/rules/architecture.md new file mode 100644 index 00000000..13700ee1 --- /dev/null +++ b/.agents/rules/architecture.md @@ -0,0 +1,207 @@ +# Architecture Rules + +## Package Structure + +```text +src/main/kotlin/com/weeth/ +├── domain/{domain-name}/ +│ ├── application/ +│ │ ├── dto/request/, dto/response/ +│ │ ├── mapper/ +│ │ ├── usecase/ +│ │ │ ├── command/ # State-changing use cases +│ │ │ └── query/ # Read-only query services +│ │ ├── exception/ +│ │ └── validator/ +│ ├── domain/ +│ │ ├── entity/ # Rich Domain Model +│ │ ├── vo/ # Value Objects +│ │ ├── enums/ +│ │ ├── port/ # External system abstraction (Port interface) +│ │ ├── service/ # Multi-entity business logic only +│ │ └── repository/ +│ ├── infrastructure/ # Port implementations (Adapter) +│ └── presentation/ +│ └── *Controller.kt +└── global/ + ├── auth/ + ├── config/ + ├── common/ + └── logging/ +``` + +## Layer Dependencies + +```text +presentation → application → domain (owns Port) + ↑ + infrastructure (implements Port) +``` + +- **presentation** → application only +- **application** → domain (Repository, Entity, Service, Port). Never import infrastructure directly +- **domain** → depends on nothing. Owns Port interfaces +- **infrastructure** → implements domain/port. Depends on external libraries/SDK +- **Same domain**: UseCase uses Repository directly +- **Cross-domain read**: via target domain's Reader interface (not Repository directly) +- **Cross-domain write**: Repository directly (same transaction required) +- **Cross-domain write**: Use Domain Event (transaction separable) + +## UseCase Rules + +| Type | Package | Naming | Transaction | +|------|---------|--------|-------------| +| Command | `usecase/command/` | `{Verb}{Domain}UseCase` | `@Transactional` | +| Query | `usecase/query/` | `Get{Domain}QueryService` | `@Transactional(readOnly = true)` | + +- **Orchestration only**: delegates business logic to Entity, calls Repository directly +- **No wrapper services**: do NOT create GetService/SaveService/DeleteService for thin Repository delegation +- **Group related actions**: e.g. `AuthUserUseCase` = login + signup + withdraw + +## Query Service + +- **Role**: data assembly for presentation (query, map, combine, paginate) — not business logic +- **Transaction**: `@Transactional(readOnly = true)` +- **Return type**: Response DTO +- **Prohibited**: state changes, business logic execution + +### Command UseCase → Query Service dependency + +| Situation | Recommendation | +|-----------|----------------| +| Simple `findById` + exception | Use Repository directly | +| Complex query returning Entity | Depend on Query Service OK | +| Query Service returns Response DTO | Do NOT depend — use Reader or Repository | + +## Cross-domain Reference + +- **Read**: Reader interface in target domain (`domain/repository/`), implemented by Repository +- **Write**: Repository directly (same transaction required) + +## Entity (Rich Domain Model) + +- **State changes**: named methods (`publish()`, `softDelete()`) — no public setters +- **Validation**: `require` for argument checks, `check` for state preconditions +- **Business decisions**: `isEditableBy()`, `canPublish()` belong to Entity + +### Constructor Pattern + +Primary constructor takes **business creation params only** (non-property) — JPA-managed fields (`id`, `isDeleted`) belong in the body with `private set` and default values. + +```kotlin +@Entity +class Post( + title: String, + content: String, + user: User, + board: Board, +) : BaseEntity() { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + var title: String = title + private set + // ... + + companion object { + fun create(title: String, content: String, user: User, board: Board): Post { + require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다" } + return Post(title = title, content = content, user = user, board = board) + } + } +} +``` + +| Concern | Location | +|---------|----------| +| JPA-managed fields (`id`, `isDeleted`) | Body, `private set`, default value | +| Business creation params | Primary constructor (non-property) | +| Validation | `create()` / named mutation methods — not constructor | + +- **Factory method** (`companion object`): use when the entity has creation logic or validation. Expresses domain intent. +- **Simple entities** (e.g., `Board`): public constructor is fine; no factory method needed if creation is trivial. + +## Value Object (VO) + +- **Location**: `domain/vo/` +- **Single field**: Kotlin `value class` — inline at JVM level, zero overhead +- **Multi field**: `@Embeddable data class` — used with `@Embedded` in Entity + +### value class (single field) + +```kotlin +@JvmInline +value class Email(val value: String) { + init { + require(value.contains("@")) { "Invalid email format: $value" } + } +} +``` + +### @Embeddable data class (composite fields) + +```kotlin +@Embeddable +data class Period( + @Column(nullable = false) + val startDate: LocalDate, + + @Column(nullable = false) + val endDate: LocalDate, +) { + init { + require(!endDate.isBefore(startDate)) { "endDate must be after startDate" } + } + + fun contains(date: LocalDate): Boolean = + !date.isBefore(startDate) && !date.isAfter(endDate) +} +``` + +### Usage in Entity + +```kotlin +@Entity +class User( + @Embedded + val period: Period, + + // value class stored as primitive via .value + @Column(nullable = false) + val email: String, // Entity field is primitive; VO conversion at UseCase/Service boundary +) +``` + +### VO Rules + +| Rule | Description | +|------|-------------| +| Immutable | All fields `val`; return new instance on state change | +| Self-validating | Validate with `require` in `init` block | +| Equality | value class: automatic; data class: `equals/hashCode` auto-generated | +| Business logic | May contain operations/decisions relevant to the value | +| JPA mapping | `@Embeddable` + `@Embedded` for composite; value class stored as primitive in Entity | + +## Domain Service + +- **Only for multi-entity logic** or rules that don't fit a single Entity +- **No thin wrappers**: do NOT create `{Domain}GetService`, `{Domain}SaveService` +- **No `@Transactional`**: UseCase manages transaction boundaries +- **Name by role**: `AttendancePolicy`, `DuplicateCheckService` + +## Port-Adapter Pattern + +- **Port** (`domain/port/`): interface in domain language → `FileStoragePort`, `PushNotificationSenderPort` +- **Adapter** (`infrastructure/`): implementation with tech prefix → `S3FileStorageAdapter`, `FcmPushNotificationSenderAdapter` +- UseCase depends on Port interface only → swappable, testable + +## Core Principles + +1. **Rich Domain Model**: Entity owns validation, state changes, and business decisions +2. **UseCase = orchestration**: coordinates flow; "how" is decided by Entity +3. **No meaningless services**: Repository wrappers are eliminated; Domain Service only for multi-entity logic +4. **Port-Adapter**: domain owns Port interfaces; infrastructure implements them +5. **Kotlin-first**: Java → Kotlin migration complete; all new code in Kotlin diff --git a/.agents/rules/code-style.md b/.agents/rules/code-style.md new file mode 100644 index 00000000..36897d4e --- /dev/null +++ b/.agents/rules/code-style.md @@ -0,0 +1,88 @@ +# Code Style Rules + +## Language + +- Primary: Kotlin only. Do not introduce Java production code. +- Build: Gradle (Kotlin DSL) + +## Formatting + +- Use ktlint +- Run `./gradlew ktlintFormat` before committing + +## Naming Conventions + +| Element | Convention | Example | +|---------|-----------|---------| +| Classes | PascalCase | `UserController`, `CreateUserUseCase` | +| Methods | camelCase | `getUserDetail`, `createUser` | +| Constants | SCREAMING_SNAKE_CASE | `MAX_PAGE_SIZE` | +| Packages | lowercase | `com.example.domain.user` | +| DTOs | Suffix with purpose | `CreateUserRequest`, `UserResponse` | +| Test Fixtures | `{Entity}TestFixture` | `UserTestFixture` | + +## Null Safety + +- Avoid using Kotlin non-null assertion operator `!!`. +- Prefer safe call (`?.`), Elvis operator (`?:`), and `requireNotNull`/`checkNotNull` unless `!!` is truly unavoidable. +- If `!!` must be used, add a short comment explaining why in that code block. + +## Data Class vs Class + +```kotlin +// Request DTO - Use data class +data class CreateUserRequest( + @field:NotBlank val name: String, + @field:Email val email: String +) + +// Response DTO - Use data class +data class UserResponse( + val id: Long, + val name: String +) + +// Entity - Use class (not data class) +@Entity +class User( + @Id @GeneratedValue + val id: Long = 0, + var name: String +) : BaseEntity() +``` + +## Import Organization + +1. Kotlin standard library +2. Third-party libraries +3. Spring framework +4. Project classes + +## Constants + +```kotlin +companion object { + private const val MAX_PAGE_SIZE = 20 + private const val DEFAULT_PAGE_SIZE = 10 +} +``` + +## Comments + +- Do NOT comment on self-explanatory code +- Add comments in these cases: + - **Core business logic**: Domain rules, policy decisions — explain "why", not "what" + - **Collaboration aid**: Intent or background that other developers need to understand the code + - **Non-obvious implementation**: Performance optimizations, workarounds, external system constraints + - **Architecture decisions**: Reason for choosing a specific pattern or structure +- Use KDoc (`/** */`) for public APIs, Port interfaces, and external contracts +- Use inline comments (`//`) for implementation intent within methods + +## Null Handling + +```kotlin +// Use nullable types and Elvis operator +fun getUser(userId: Long): User = + userRepository.findByIdOrNull(userId) + ?: throw UserNotFoundException() +``` diff --git a/.agents/rules/exception-handling.md b/.agents/rules/exception-handling.md new file mode 100644 index 00000000..5bee6001 --- /dev/null +++ b/.agents/rules/exception-handling.md @@ -0,0 +1,136 @@ +# Exception Handling Rules + +## Exception Hierarchy + +``` +RuntimeException + └── BaseException (abstract) + ├── UserNotFoundException + ├── BoardNotFoundException + └── ... (domain-specific exceptions) +``` + +## Base Exception + +```kotlin +abstract class BaseException( + val errorCode: ErrorCodeInterface, + message: String? = null +) : RuntimeException(message ?: errorCode.message) +``` + +## Error Code Interface + +```kotlin +interface ErrorCodeInterface { + val code: Int + val status: HttpStatus + val message: String + + fun getExplainError(): String = message +} +``` + +## Domain Error Codes + +```kotlin +enum class UserErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String +) : ErrorCodeInterface { + @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") + USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), + + @ExplainError("가입 승인 대기 중인 사용자가 접근을 시도할 때 발생합니다.") + USER_INACTIVE(20901, HttpStatus.FORBIDDEN, "가입 승인이 허가되지 않은 계정입니다."), + + @ExplainError("이미 가입된 이메일로 회원가입을 시도할 때 발생합니다.") + USER_EXISTS(20902, HttpStatus.BAD_REQUEST, "이미 가입된 사용자입니다."), +} +``` + +## Common Error Codes (pattern example, not yet implemented) + +Follow the pattern below when introducing a common error code enum. Currently, `CommonExceptionHandler` uses `CommonResponse.createFailure()` directly. + +```kotlin +enum class CommonErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String +) : ErrorCodeInterface { + // 3DDNN: Infra/Server errors (DD=99 for common) + INTERNAL_SERVER_ERROR(39901, HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), + JSON_PROCESSING_ERROR(39902, HttpStatus.INTERNAL_SERVER_ERROR, "JSON processing error"), + + // 4DDNN: Client/Validation errors (DD=99 for common) + INVALID_ARGUMENT(49901, HttpStatus.BAD_REQUEST, "Invalid argument"), + RESOURCE_NOT_FOUND(49903, HttpStatus.NOT_FOUND, "Resource not found"), +} +``` + +## Domain Exception Classes + +```kotlin +class UserNotFoundException : BaseException(UserErrorCode.USER_NOT_FOUND) +``` + +## Swagger Exception Documentation (Auto) + +Swagger is customized so exception codes/examples are registered automatically from annotations and error-code enums. + +### Required Annotations + +- `@ApiErrorCodeExample`: Declare which `ErrorCodeInterface` enums can be returned by an API. +- `@ExplainError`: Optional field-level description for richer Swagger examples. + +```kotlin +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ApiErrorCodeExample( + vararg val value: KClass +) +``` + +### Controller Convention + +- Apply `@ApiErrorCodeExample` at controller class level when most endpoints share the same domain errors. +- Apply it at method level when a specific endpoint has different error sets. +- If both are present, method-level declaration should take precedence for that endpoint. +- If multiple enums are needed, pass them together: + +```kotlin +@ApiErrorCodeExample(BoardErrorCode::class, NoticeErrorCode::class) +class NoticeController +``` + +### ErrorCode Enum Convention + +- Domain error enums must implement `ErrorCodeInterface`. +- Add `@ExplainError` to each enum constant when possible. +- If `@ExplainError` is missing, fallback to `message`. + +```kotlin +enum class UserErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String +) : ErrorCodeInterface { + @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") + USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), +} +``` + +### Documentation-only Controller + +- Keep an `ExceptionDocController` for aggregated, domain-wide exception browsing in Swagger. +- This controller is for documentation only; it should not contain business logic. + +### When Adding a New Exception + +1. Add enum constant to the proper `*ErrorCode`. +2. Add `@ExplainError` description. +3. Create/adjust domain exception class extending `BaseException`. +4. Ensure the relevant controller/method has `@ApiErrorCodeExample` for that enum. +5. Verify Swagger examples show the new code without manual response-spec edits. diff --git a/.agents/rules/git-conventions.md b/.agents/rules/git-conventions.md new file mode 100644 index 00000000..45d28d13 --- /dev/null +++ b/.agents/rules/git-conventions.md @@ -0,0 +1,112 @@ +# Git Conventions Rules + +# Commit Convention +## Format + +``` +type: message +``` + +- **type**: lowercase English +- **message**: Brief description (imperative mood) + +## Types + +| Type | Description | Example | +|------|-------------|---------| +| `feat` | New feature | `feat: Add user authentication` | +| `fix` | Bug fix | `fix: Resolve null pointer in service` | +| `refactor` | Code refactoring | `refactor: Extract validation logic` | +| `test` | Test code changes | `test: Add UserService unit tests` | +| `docs` | Documentation | `docs: Update API documentation` | +| `style` | Code formatting | `style: Apply code formatter` | +| `chore` | Maintenance | `chore: Update dependencies` | +| `perf` | Performance improvement | `perf: Optimize database queries` | +| `ci` | CI configuration | `ci: Add GitHub Actions workflow` | +| `build` | Build system | `build: Update Gradle config` | + +## Examples + +```bash +# New feature +feat: Add user registration endpoint + +# Bug fix +fix: Handle null profile in user response + +# Refactoring +refactor: Split UserService into Get/Save services + +# Test +test: Add integration tests for auth flow + +# Documentation +docs: Add API usage examples + +# Style +style: Format code with ktlint + +# Chore +chore: Upgrade Spring Boot to 3.2.0 +``` + +## Rules + +1. **No period** at the end +2. **Imperative mood** ("Add" not "Added", "Fix" not "Fixed") +3. **50 characters or less** for subject line +4. **Separate subject from body** with blank line if body needed +5. **Reference issue numbers** if applicable: `fix: Resolve login bug (#123)` + +## Multi-line Commits + +For detailed descriptions: + +```bash +git commit -m "$(cat <<'EOF' +feat: Add user authentication + +- Implement JWT token generation +- Add login/logout endpoints +- Create auth middleware + +Closes #123 +EOF +)" +``` +--- +# Branch Convention + +| Type | Pattern | Example | +|------|---------|------------------------------| +| Feature | `feat/{ticket}-description` | `feat/WTH-123-user-login` | +| Bugfix | `fix/{ticket}-description` | `fix/WTH-456-token-expiry` | +| Refactor | `refactor/{ticket}-description` | `refactor/WTH-789-cleanup` | +| Hotfix | `hotfix/description` | `hotfix/critical-auth-bug` | +| Release | `release/version` | `release/v1.2.0` | + +## Branch Update Policy + +- Update local branches from the latest target branch using **merge**. +- Default command: `git merge origin/{target-branch}`. +- Do not rewrite shared branch history with rebase when syncing latest changes. + +## Pre-commit Checklist + +1. Run linter: `./gradlew ktlintFormat` +2. Run tests: `./gradlew test` +3. Verify commit message format +4. Review changed files +5. Check for sensitive data (.env, credentials) + +## Conventional Commits (Optional) + +For automated changelog generation: + +``` +type(scope): message + +feat(auth): Add OAuth2 support +fix(api): Handle rate limiting +refactor(user): Simplify validation logic +``` diff --git a/.agents/rules/mapper-dto.md b/.agents/rules/mapper-dto.md new file mode 100644 index 00000000..c1fd5289 --- /dev/null +++ b/.agents/rules/mapper-dto.md @@ -0,0 +1,123 @@ +# Mapper & DTO Rules + +## Mapper Pattern + +Manual `@Component` Mapper pattern (no MapStruct). + +```kotlin +@Component +class UserMapper { + fun toResponse(user: User) = UserResponse( + id = user.id, + name = user.name, + email = user.email, + ) + + fun toEntity(request: CreateUserRequest) = User( + name = request.name.trim(), + email = request.email.lowercase(), + status = UserStatus.ACTIVE + ) +} +``` + +## Mapper Naming + +| Method Pattern | Purpose | +|---------------|---------| +| `toResponse` | Entity → Response DTO | +| `toEntity` | Request DTO → Entity | +| `toDto` | Entity → Generic DTO | +| `from{Source}` | Convert from specific source type | + +## Request DTO + +Located in `application/dto/request/`: + +```kotlin +data class CreateUserRequest( + @field:Schema(description = "User name", example = "John Doe") + @field:NotBlank + @field:Size(max = 100) + val name: String, + + @field:Schema(description = "Email address", example = "john@example.com") + @field:NotBlank + @field:Email + val email: String, +) +``` + +### Validation Annotations + +| Annotation | Usage | +|-----------|-------| +| `@NotNull` | Field must not be null | +| `@NotEmpty` | Collection must have elements | +| `@NotBlank` | String must not be empty/whitespace | +| `@Size(min, max)` | Length/size constraints | +| `@Positive` | Number must be > 0 | +| `@Valid` | Validate nested objects | + +## Response DTO + +Located in `application/dto/response/`: + +```kotlin +data class UserResponse( + @Schema(description = "User ID", example = "1") + val id: Long, + + @Schema(description = "User name", example = "John Doe") + val name: String, +) +``` + +### Response DTO Rules + +- Use `@Schema` for OpenAPI documentation +- Use non-nullable types for required fields +- Use nullable types with default `null` for optional fields + +## List Response with Pagination (pattern example) + +Follow the pattern below when introducing a pagination response DTO. + +```kotlin +data class UserListResponse( + @Schema(description = "User list") + val users: List, + + @Schema(description = "Pagination info") + val page: PageResponse +) + +data class PageResponse( + val pageNumber: Int, + val pageSize: Int, + val totalElements: Long, + val totalPages: Int, + val hasNext: Boolean +) { + companion object { + fun from(page: Page<*>) = PageResponse( + pageNumber = page.number, + pageSize = page.size, + totalElements = page.totalElements, + totalPages = page.totalPages, + hasNext = page.hasNext() + ) + } +} +``` + +## Mapper Dependencies + +Mappers can inject other mappers when needed: + +```kotlin +@Component +class PostMapper( + private val commentMapper: CommentMapper +) +``` diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md new file mode 100644 index 00000000..e99df07e --- /dev/null +++ b/.agents/rules/testing.md @@ -0,0 +1,125 @@ +# Testing Rules + +## Frameworks + +| Framework | Purpose | +|-----------|---------| +| Kotest | Kotlin test framework | +| MockK | Kotlin mocking | +| springmockk | Spring bean mocking (`@MockkBean`) | +| Testcontainers | Integration tests (DB, Redis, etc.) | + +## Test Styles (Kotest) + +| Style | Use Case | +|-------|----------| +| `DescribeSpec` | Default for application tests (Command UseCase, QueryService) | +| `BehaviorSpec` | Complex business logic requiring BDD (Given/When/Then) | +| `StringSpec` | Simple validation and pure domain logic tests | + +## Directory Structure + +```text +src/test/kotlin/com/weeth/domain/{domain-name}/ +├── application/usecase/command/ # Command UseCase tests +├── application/usecase/query/ # QueryService tests +├── domain/service/ # Domain service tests (multi-entity logic) +├── domain/entity/ # Entity behavior tests +└── fixture/ # Shared fixtures for the domain +``` + +## Naming Conventions + +| Element | Convention | Example | +|---------|-----------|---------| +| Test class | `{ClassName}Test` | `CreateUserUseCaseTest`, `GetUserQueryServiceTest` | +| Test fixture | `{Entity}TestFixture` | `UserTestFixture` | +| DescribeSpec description | method/action + condition + behavior | `describe("execute") { context("with valid request") { it("creates user") } }` | + +## Architecture-aligned Unit Boundaries + +- Command UseCase test: mock Repository/Reader/Port, verify orchestration behavior. +- QueryService test: verify read-only assembly (query/map/combine/paginate), no state mutation. +- Entity test: verify `create/of`, state transitions, `require/check`, and business decisions. +- Domain Service test: only for multi-entity logic/policy classes (not thin wrappers). +- Controller test: verify request/response contract and serialization with `@WebMvcTest`. + +## Dependency Rules in Tests + +- Same-domain dependencies: UseCase mocks Repository directly. +- Cross-domain read: mock target domain Reader interface (not target Repository directly). +- Cross-domain write: mock target domain Repository directly when same-transaction write is required. +- Port-Adapter: application tests mock Port interface, not infrastructure adapter implementations. + +## Unit Test vs Integration Test + +| Category | Unit Test | Integration Test | +|----------|-----------|-----------------| +| Scope | Single class | Multiple layers / external systems | +| Dependencies | MockK mocks | Testcontainers (DB, Redis) | +| Speed | Fast (ms) | Slow (seconds) | +| Annotation | None | `@SpringBootTest`, `@WebMvcTest` | +| When to use | Orchestration, branching, entity/domain rules | DB queries, API endpoints, transaction behavior | + +## Fixture Pattern + +```kotlin +object UserTestFixture { + fun createUser( + id: Long = 1L, + email: String = "test@example.com", + name: String = "Test User" + ) = User(id = id, name = name, email = email, status = UserStatus.ACTIVE) +} +``` + +- Location: `src/test/kotlin/com/weeth/domain/{domain-name}/fixture/` +- Use `object` with factory methods +- Provide sensible defaults for all parameters +- Reuse across test classes in the same domain + +## What to Test / Skip + +**Write tests for:** +- UseCase orchestration paths (success/failure/branching) +- Reader/Repository/Port interaction contracts (`verify`) +- QueryService data assembly and pagination mapping +- Entity invariants and state transitions (`require`/`check`) +- Exception scenarios and error-code mapping + +**Skip tests for:** +- Thin wrapper methods that only delegate to Repository without logic +- Getter/setter, trivial DTO mapping +- Framework-provided functionality + +## Mock Lifecycle in DescribeSpec + +MockK mocks are **not** automatically cleared between `it` blocks. Without clearing, accumulated invocations cause `verify(exactly = N)` to fail in subsequent tests. + +Always add `beforeTest { clearMocks(...) }` when mocks are shared: + +```kotlin +class SomeUseCaseTest : DescribeSpec({ + val repository = mockk() + val useCase = SomeUseCase(repository) + + beforeTest { + clearMocks(repository) + // Re-stub defaults after clearing + every { repository.save(any()) } answers { firstArg() } + } + + describe("someMethod") { + it("case 1") { verify(exactly = 1) { repository.save(any()) } } + it("case 2") { verify(exactly = 1) { repository.save(any()) } } // OK - count reset + } +}) +``` + +## Running Tests + +```bash +./gradlew test # All tests +./gradlew test --tests "*UseCaseTest" # Pattern match +./gradlew test --tests "CreateUserUseCaseTest" # Specific class +``` diff --git a/.agents/rules/transaction-concurrency.md b/.agents/rules/transaction-concurrency.md new file mode 100644 index 00000000..c7c24665 --- /dev/null +++ b/.agents/rules/transaction-concurrency.md @@ -0,0 +1,141 @@ +# Transaction & Concurrency Rules + +## Transaction Annotations + +### Read Operations +```kotlin +@Transactional(readOnly = true) +fun getFeedDetail(feedId: Long): FeedDetailResponse { + // Query operations only +} +``` + +### Write Operations +```kotlin +@Transactional +fun uploadFeed(userId: Long, request: FeedUploadRequest) { + // Create/Update/Delete operations +} +``` + +## Transaction Placement + +- Place `@Transactional` on **UseCase** methods +- Domain Services should NOT have `@Transactional` +- Let UseCase manage transaction boundaries + +```kotlin +@Service +class CreateFeedUseCase( + private val feedRepository: FeedRepository, + private val mediaRepository: MediaRepository, + private val userReader: UserReader, + private val feedMapper: FeedMapper +) { + @Transactional + fun execute(userId: Long, request: FeedUploadRequest) { + val user = userReader.findById(userId) + ?: throw UserNotFoundException() + val feed = feedMapper.toEntity(user, request.description) + feedRepository.save(feed) + val mediaList = request.media.map { Media.create(feed, it) } + mediaRepository.saveAll(mediaList) + } +} +``` + +## Pessimistic Locking + +For resources that need concurrent access control: + +```kotlin +interface FeedRepository : JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT f FROM Feed f WHERE f.id = :id") + fun findByIdWithLock(@Param("id") id: Long): Feed? +} +``` + +## When to Use Locking + +| Scenario | Lock Type | +|----------|-----------| +| Counter updates (reaction count) | PESSIMISTIC_WRITE | +| Concurrent modifications | PESSIMISTIC_WRITE | +| Read-heavy, write-rare | OPTIMISTIC (version field) | + +## Lock Timeout Handling + +```kotlin +@Service +class ReactionUsecase( + private val feedRepository: FeedRepository +) { + @Transactional + fun react(userId: Long, feedId: Long) { + try { + val feed = feedRepository.findByIdWithLock(feedId) + ?: throw FeedNotFoundException() + // process reaction + } catch (e: PessimisticLockingFailureException) { + throw ResourceLockedException() + } + } +} +``` + +## Optimistic Locking + +Add version field to entity: + +```kotlin +@Entity +class Feed( + @Version + val version: Long = 0 +) : BaseEntity() +``` + +## Transaction Propagation + +Default propagation is `REQUIRED`. Use others when needed: + +```kotlin +// New transaction (for audit logs, etc.) +@Transactional(propagation = Propagation.REQUIRES_NEW) +fun logAction(action: String) { } + +// No transaction +@Transactional(propagation = Propagation.NOT_SUPPORTED) +fun nonTransactionalOperation() { } +``` + +## Transaction Isolation + +Default is database default. Adjust for specific needs: + +```kotlin +@Transactional(isolation = Isolation.SERIALIZABLE) +fun criticalOperation() { } +``` + +## Async Operations + +For async operations, transaction context is NOT propagated: + +```kotlin +@Async +@Transactional +fun asyncOperation() { + // New transaction in async thread +} +``` + +## Best Practices + +1. **Keep transactions short** - Don't do I/O operations inside transactions +2. **Avoid nested transactions** - Can cause unexpected behavior +3. **Lock ordering** - Always acquire locks in same order to prevent deadlocks +4. **Timeout configuration** - Always set lock timeouts +5. **Handle lock exceptions** - Convert to user-friendly errors diff --git a/.agents/skills/architecture-guide/SKILL.md b/.agents/skills/architecture-guide/SKILL.md new file mode 100644 index 00000000..8082f224 --- /dev/null +++ b/.agents/skills/architecture-guide/SKILL.md @@ -0,0 +1,273 @@ +--- +name: architecture-guide +description: "Show architecture patterns with code examples. Use when asked to 'show architecture', 'architecture example', 'how to structure', or when implementing new features/domains." +--- + +# Architecture Guide + +Provide architecture pattern examples for the current task. +**All output MUST be written in Korean.** + +## Reference: architecture rule + +Always read `.agents/rules/architecture.md` first for core rules. + +--- + +## UseCase Example + +### Command UseCase + +```kotlin +@Service +class CreatePostUseCase( + private val postRepository: PostRepository, // Same domain → Repository directly + private val userReader: UserReader, // Cross-domain → Reader interface + private val fileStorage: FileStoragePort, // Port interface + private val postMapper: PostMapper // Mapper +) { + @Transactional + fun execute(userId: Long, request: CreatePostRequest): PostResponse { + val user = userReader.findById(userId) + ?: throw UserNotFoundException() + val imageUrl = fileStorage.upload(request.image) + val post = Post.create(request.title, request.content, imageUrl, user) + postRepository.save(post) + return postMapper.toResponse(post) + } +} +``` + +### Query Service + +Query Service is the layer for **assembling data for read requests**. Its core purpose is presentation-oriented data composition, not business logic. + +```kotlin +@Service +class GetPostQueryService( + private val postRepository: PostRepository, + private val postMapper: PostMapper +) { + @Transactional(readOnly = true) + fun findById(postId: Long): PostResponse { + val post = postRepository.findByIdOrNull(postId) + ?: throw PostNotFoundException() + return postMapper.toResponse(post) + } + + @Transactional(readOnly = true) + fun findAll(pageable: Pageable): Page { + return postRepository.findAll(pageable) + .map { postMapper.toResponse(it) } + } +} +``` + +| Item | Rule | +|------|------| +| Role | Data retrieval, mapping, composition, paging | +| Transaction | `@Transactional(readOnly = true)` | +| Return type | Response DTO | +| Prohibited | State mutation, business logic execution | + +### Query Service Dependency from Command UseCase + +| Scenario | Recommendation | +|------|------| +| Simple `findById` + exception | Call Repository directly | +| Complex query where Query Service returns Entity | Dependency is acceptable | +| Query Service returns Response DTO | Do not depend on it | + +### UseCase Does / Does Not + +| Does (orchestration) | Does NOT (delegate to Entity) | +|----------------------|-------------------------------| +| Repository calls (find, save) | Business validation | +| Transaction boundary | State change logic | +| DTO ↔ Entity (Mapper) | Domain rule decisions | +| Port calls (external systems) | Value calculations, policy | + +--- + +## Cross-domain Reference + +### Read: Reader Interface + +When reading data from another domain, use a **read-only interface** instead of the full Repository. + +```kotlin +// Defined in user domain (domain/repository/) +interface UserReader { + fun findById(id: Long): User? + fun existsById(id: Long): Boolean +} + +// UserRepository extends UserReader +interface UserRepository : JpaRepository, UserReader +``` + +### Write: Direct Repository Dependency + +When cross-domain writes are required (same transaction is mandatory): + +```kotlin +@Service +class CreateOrderUseCase( + private val orderRepository: OrderRepository, + private val productRepository: ProductRepository // Cross-domain write → Repository directly +) { + @Transactional + fun execute(request: CreateOrderRequest): OrderResponse { + val product = productRepository.findByIdOrNull(request.productId) + ?: throw ProductNotFoundException() + product.decreaseStock(request.quantity) + val order = Order.create(product, request.quantity) + orderRepository.save(order) + return orderMapper.toResponse(order) + } +} +``` + +### Cross-domain Reference Summary + +| Scenario | Approach | +|------|------| +| Cross-domain read | Reader interface | +| Cross-domain write (same transaction required) | Direct Repository dependency | + +--- + +## Entity (Rich Domain Model) Example + +```kotlin +@Entity +class Post( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + var title: String, + var content: String, + @Enumerated(EnumType.STRING) + var status: PostStatus = PostStatus.DRAFT, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + val author: User +) : BaseEntity() { + + companion object { + fun create(title: String, content: String, imageUrl: String?, author: User): Post { + require(title.isNotBlank()) { "Title must not be blank" } + require(content.length <= 5000) { "Content must be 5000 chars or less" } + return Post(title = title, content = content, author = author) + } + } + + fun publish() { + check(status == PostStatus.DRAFT) { "Only DRAFT posts can be published" } + status = PostStatus.PUBLISHED + } + + fun update(title: String, content: String) { + check(status != PostStatus.DELETED) { "Deleted posts cannot be updated" } + this.title = title + this.content = content + } + + fun softDelete() { + status = PostStatus.DELETED + } + + fun isEditableBy(userId: Long): Boolean = + author.id == userId +} +``` + +### Entity Patterns + +| Pattern | How | +|---------|-----| +| Creation | `companion object` factory (`create`, `of`) with `require` validation | +| State change | Named methods (`publish`, `softDelete`) — no public setters | +| State validation | `check` for preconditions | +| Business decision | `isEditableBy()`, `canPublish()` | + +--- + +## Domain Service Example + +Only create when logic spans multiple entities: + +```kotlin +@Service +class AttendancePolicy( + private val attendanceRepository: AttendanceRepository +) { + fun validateWeeklyLimit(user: User, date: LocalDate): Boolean { + val weeklyCount = attendanceRepository.countByUserAndWeek(user.id, date) + return weeklyCount < MAX_WEEKLY_ATTENDANCE + } + + companion object { + private const val MAX_WEEKLY_ATTENDANCE = 5 + } +} +``` + +### When to Create / Not Create + +| Create (multi-entity logic) | Do NOT Create (use alternatives) | +|-----------------------------|----------------------------------| +| `TransferService` (cross-account) | `findById` + exception → UseCase calls Repository | +| `AttendancePolicy` (policy check) | `save` delegation → UseCase calls Repository | +| `DuplicateCheckService` (uniqueness) | Single entity state change → Entity method | + +--- + +## Port-Adapter Example (FileStorage) + +### Port (`domain/port/`) + +```kotlin +interface FileStoragePort { + fun upload(file: MultipartFile): String + fun upload(files: List): List + fun delete(fileUrl: String) +} +``` + +### Adapter (`infrastructure/`) + +```kotlin +@Component +class S3FileStorage( + private val s3Client: S3Client, + @Value("\${cloud.aws.s3.bucket}") private val bucket: String +) : FileStoragePort { + + override fun upload(file: MultipartFile): String { + val key = generateKey(file.originalFilename) + s3Client.putObject( + PutObjectRequest.builder().bucket(bucket).key(key).build(), + RequestBody.fromInputStream(file.inputStream, file.size) + ) + return "$CDN_URL/$key" + } + + override fun upload(files: List): List = + files.map { upload(it) } + + override fun delete(fileUrl: String) { + val key = extractKey(fileUrl) + s3Client.deleteObject( + DeleteObjectRequest.builder().bucket(bucket).key(key).build() + ) + } +} +``` + +### Naming Convention + +| Port (domain/port/) | Adapter (infrastructure/) | +|------------------------------|---------------------------| +| `FileStoragePort` | `S3FileStorage` | +| `PushNotificationSenderPort` | `FcmPushNotificationSender` | +| `CacheStorePort` | `RedisCacheStore` | diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 00000000..c12a6683 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,136 @@ +--- +name: code-review +description: "Review PR/commit code changes. Detects bugs, security vulnerabilities, performance issues and provides concrete fix suggestions." +--- + +# Code Review + +Systematically review code changes, detect issues, and provide actionable fixes. +**All output MUST be written in Korean (한국어).** + +## Workflow (MUST follow in order) + +### 1. Analyze Changes +```bash +git diff HEAD~1 --name-only # or git diff --staged --name-only +git diff HEAD~1 # or git diff --staged +``` +- List changed files +- Assess scope and impact +- Check if related test files exist + +### 2. Review by Category (in order) +1. **Critical**: Bugs, security vulnerabilities, data loss risks +2. **Major**: Performance issues, architecture violations, missing tests +3. **Minor**: Code style, naming, duplicate code +4. **Suggestion**: Better implementations, Kotlin idioms + +### 3. Output Review Result +For each issue provide: +- File name and line number +- Problem description +- Severity (Critical/Major/Minor/Suggestion) +- Before/After code examples + +## Review Checklist + +### Bug/Logic +- Null safety (avoid "!!", use nullable types) +- Edge case handling +- Exception handling (must extend BaseException) +- Concurrency issues (race conditions) + +### Security +- SQL Injection (raw queries, string concatenation) +- Sensitive data exposure (logs, responses) +- Missing auth (@CurrentUser usage) +- Input validation (@Valid, @NotNull, @NotBlank) + +### Performance +- N+1 query (repository calls inside loops) +- Unnecessary DB calls +- Memory leaks (unclosed resources) + +### Architecture +- Layer adherence: Controller → UseCase → Repository (UseCase uses Repository directly) +- Rich Domain Model: business logic in Entity, not UseCase +- No thin wrapper services (GetService/SaveService) — Domain Service only for multi-entity logic +- @Transactional only on UseCase methods +- Port-Adapter: UseCase depends on Port interface, not infrastructure directly +- Cross-domain read via Reader interface, cross-domain write via Repository directly +- No layer skipping (Controller → Repository is forbidden) + +### Kotlin-specific +- val over var +- Nullable type overuse +- Scope function opportunities (let, apply, also) +- data class for DTOs +- when expression over if-else chains + +## Output Format + +Use the following Korean template: + +```markdown +# 코드 리뷰 결과 + +## 요약 +- Critical: N건 +- Major: N건 +- Minor: N건 +- Suggestion: N건 + +## Critical 이슈 +### [UserService.kt:42] 유저 조회 시 NPE 발생 가능 +**문제**: `findById` 반환값에 대한 null 처리가 누락되어 NPE가 발생할 수 있습니다. +**수정 전**: +```kotlin +val user = userRepository.findById(userId).get() +``` +**수정 후**: +```kotlin +val user = userRepository.findByIdOrNull(userId) + ?: throw UserNotFoundException() +``` + +## Major 이슈 +### [FeedUsecase.kt:28] N+1 쿼리 문제 +**문제**: 반복문 내에서 `commentRepository.findByFeedId()`를 호출하여 N+1 쿼리가 발생합니다. +**수정 전**: +```kotlin +val feeds = feedRepository.findAll() +feeds.map { feed -> + val comments = commentRepository.findByFeedId(feed.id) // N+1 + feed to comments +} +``` +**수정 후**: +```kotlin +val feeds = feedRepository.findAll() +val comments = commentRepository.findByFeedIdIn(feeds.map { it.id }) +val commentMap = comments.groupBy { it.feedId } +feeds.map { feed -> feed to (commentMap[feed.id] ?: emptyList()) } +``` + +## Minor 이슈 +### [UserController.kt:15] 불필요한 `var` 사용 +**문제**: 재할당이 없는 변수에 `var`를 사용하고 있습니다. `val`로 변경하세요. + +## Suggestion +### [UserMapper.kt:10] scope function 활용 +**제안**: `also` 블록을 사용하면 로깅과 변환을 깔끔하게 분리할 수 있습니다. + +## 좋은 점 +- UseCase에서 트랜잭션 경계를 잘 관리하고 있습니다. +- 커스텀 예외 패턴이 일관성 있게 적용되어 있습니다. + +## 전체 평가 +⚠️ 수정 필요 - Critical 1건, Major 1건 수정 후 재확인 부탁드립니다. +``` + +## Rules +- **All output in Korean (한국어)** +- Always provide concrete fix code, not just criticism +- Praise good code when found +- Mark uncertain issues as "확인 필요" +- If no issues found, state "리뷰 완료 - 이슈 없음" diff --git a/.agents/skills/context-update/SKILL.md b/.agents/skills/context-update/SKILL.md new file mode 100644 index 00000000..8498e1a5 --- /dev/null +++ b/.agents/skills/context-update/SKILL.md @@ -0,0 +1,142 @@ +--- +name: context-update +description: Self-feedback skill that analyzes completed work and improves Codex context. Use when asked to "update context", "capture learnings", "improve context", or before compaction. Identifies reusable patterns and delegates to appropriate create skills. +--- + +# Context Update + +Meta-skill for continuous improvement through self-reflection. + +## Purpose + +After completing tasks, analyze work and: +1. Identify reusable patterns +2. Find gaps in existing context +3. Delegate to appropriate create skills +4. Generate improvement report + +## Workflow + +### Step 1: Analyze Session + +Review conversation for: + +``` +[ ] Tasks completed +[ ] Problems solved +[ ] Patterns repeated (3+ times = skill candidate) +[ ] External knowledge needed (gap in rules) +[ ] Friction points or mistakes +[ ] Frequently used commands +``` + +### Step 2: Categorize Findings + +| Signal | Category | Action | +|--------|----------|--------| +| Reusable 3+ step pattern | Skill | Invoke `skill-create` | +| Convention discovered | Rule | Invoke `rule-create` | +| One-off task | None | Document only | + +### Step 3: Check for Duplicates + +Before delegating, search existing context: + +```bash +Glob: .agents/skills/*/SKILL.md +Glob: .agents/rules/*.md +Grep: pattern="{keyword}" path=".agents/" +``` + +### Step 4: Delegate Creation + +For each identified improvement, invoke the appropriate skill: + +- **New skill needed** → Invoke `skill-create` +- **Rule update needed** → Invoke `rule-create` +- **Neither applies** → Update MEMORY.md or document in report only + +### Step 5: Generate Report + +```markdown +## Context Update Report + +### Session Summary +- Tasks: {list of completed tasks} +- Patterns identified: {count} + +### Actions Taken + +| Type | Name | Action | Reason | +|------|------|--------|--------| +| Skill | {name} | Created | {why} | +| Rule | {file} | Updated | {why} | + +### Skipped (No Action) + +| Pattern | Reason | +|---------|--------| +| {pattern} | One-off / Too specific / Already exists | + +### Manual Follow-ups +- {Any suggestions requiring user decision} +``` + +## Decision Criteria + +### Create When: +- Pattern used 3+ times +- Would save significant time if reused +- Not too project-specific +- Clear trigger phrases exist + +### Skip When: +- One-off task +- Too project-specific +- Already documented +- Requires user decision (suggest instead) + +## Conflict Resolution Priority + +When a newly discovered pattern conflicts with existing guidance, apply this order: + +1. Follow higher-priority runtime instructions (system/developer/user for the current session). +2. Prefer existing project rules in `.agents/rules/` over ad-hoc new patterns. +3. If no rule exists, follow established skill workflows in `.agents/skills/*/SKILL.md`. +4. Treat the new pattern as a candidate update, not an immediate override. +5. If conflict remains ambiguous, do not auto-apply; add it to **Manual Follow-ups** for user decision. + +Implementation guidance: +- For rule conflicts, invoke `rule-create` to update/clarify the rule with rationale. +- For skill workflow conflicts, invoke `skill-create` only if the change is broadly reusable. +- Always document why the existing guidance was kept or updated in the report. + +## Example Session Analysis + +**Observed**: Created API endpoint 4 times with same structure. + +**Analysis**: +- Repeated pattern? ✓ (4 times) +- Multi-step? ✓ (Controller, Service, DTO, tests) +- Reusable? ✓ (applies to any endpoint) + +**Action**: Check if `api-create` skill exists → Already exists, no action. + +--- + +**Observed**: Had to look up soft-delete query pattern twice. + +**Analysis**: +- Caused friction? ✓ +- Convention exists? Partially in entity-repository.md + +**Action**: Invoke `rule-create` to update relevant rule with explicit example. + +--- + +**Observed**: Wrote one-time data migration script. + +**Analysis**: +- Repeated? ✗ (one-off) + +**Action**: None - too specific. diff --git a/.agents/skills/database-manage/SKILL.md b/.agents/skills/database-manage/SKILL.md new file mode 100644 index 00000000..3558ec12 --- /dev/null +++ b/.agents/skills/database-manage/SKILL.md @@ -0,0 +1,222 @@ +--- +name: database-manage +description: DB schema inspection and management. Use when asked to "show schema", "show tables", "check DB", "DB context", "database info", "스키마 덤프", "엔티티 구조", "테이블 확인", "스키마 확인". +--- + +# Database Manage + +Inspect DB schema, analyze entity structures, and dump live schema from MySQL. +**All output MUST be written in Korean (한국어).** + +## Schema Information Sources (Priority Order) + +1. **Schema snapshot** - Read `references/schema.md` if it exists (fastest) +2. **Schema dump script** - Dump live schema from DB (`scripts/dump-schema.sh`) +3. **Code-based analysis** - Scan entity/repository files (no DB connection required) + +## Instructions + +### Step 1: Check Schema Snapshot + +Check if an existing snapshot is available. + +``` +Read: .agents/skills/database-manage/references/schema.md +``` + +- If file exists → respond based on this file +- If file is missing or outdated → proceed to Step 2 or Step 3 + +### Step 2: Dump Live Schema from DB + +If local MySQL is running, use the script to fetch the latest schema. + +```bash +# Pass connection info via environment variables +DB_HOST=localhost DB_PORT=3306 DB_USER=root DB_PASSWORD= DB_NAME=weeth \ + .agents/skills/database-manage/scripts/dump-schema.sh + +# Or pass via arguments +.agents/skills/database-manage/scripts/dump-schema.sh -h localhost -P 3306 -u root -p -d weeth +``` + +**IMPORTANT**: Never guess passwords. Always ask the user or read from environment variables. + +Script output: +- Saves full schema to `references/schema.md` +- Includes table list, columns, indexes, FK relationships + +### Step 3: Code-Based Analysis (When DB Is Unavailable) + +When DB connection is not possible, analyze from code only. + +#### 3-1. Check Project DB Configuration +- Use `Glob` to search `**/application*.{yml,yaml,properties}` +- Check datasource URL, driver, dialect +- Check ddl-auto setting, migration tool configuration + +#### 3-2. Analyze Entity Structure +- Use `Grep` to find files with `@Entity` annotation +- Analyze fields, relationships (@OneToMany, @ManyToOne, etc.), indexes per entity +- Check BaseEntity inheritance structure +- Check `@Table(name = "...")` mappings + +#### 3-3. Analyze Repositories +- Use `Grep` to search for `JpaRepository`, `@Query`, `@Lock` +- Check custom query methods + +#### 3-4. Check Migration Files +- Use `Glob` to search `**/db/migration/**/*.sql` + +## Script Details + +### dump-schema.sh + +Queries MySQL `INFORMATION_SCHEMA` and saves results to `references/schema.md`. + +**Output includes:** +- Table list (engine, row count, comments) +- Column details per table (type, nullable, key, default, extra) +- Index info (columns, uniqueness, type) +- FK relationships (referenced table/column) +- Relationship diagram (text-based) + +**Connection info methods:** +1. Environment variables: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` +2. Arguments: `-h host -P port -u user -p password -d database` +3. Defaults: `localhost:3306`, user=`root`, db=`weeth` + +## Analysis Commands by Topic + +### List All Entities + +``` +Grep: pattern="@Entity" → get file list +Read each file → extract fields/relationships +``` + +### Inspect Specific Table + +``` +Grep: pattern="class {EntityName}" → find entity file +Read → check all fields, relationships, indexes, constraints +Grep: pattern="{EntityName}" in Repository files → find related queries +``` + +### Relationship Map + +``` +Grep: pattern="@(OneToMany|ManyToOne|OneToOne|ManyToMany)" → map all relationships +``` + +### Check Indexes + +``` +Grep: pattern="@Index|@Table.*indexes" → find index definitions +``` + +## Output Format + +### Full Schema Summary + +```markdown +# DB 스키마 요약 + +## DB 설정 +- **DB 종류**: MySQL 8.0 +- **DDL 전략**: validate +- **마이그레이션**: Flyway / 없음 + +## 엔티티 목록 + +| 엔티티 | 테이블명 | 주요 필드 | 관계 | +|--------|----------|-----------|------| +| User | users | id, name, email, role | Profile(1:1), Post(1:N) | +| Post | posts | id, title, content, userId | User(N:1), Comment(1:N) | + +## 관계 다이어그램 (텍스트) + +User ──1:1──> Profile +User ──1:N──> Post +Post ──1:N──> Comment +Comment ──N:1──> User + +## 인덱스 + +| 테이블 | 인덱스명 | 컬럼 | 유니크 | +|--------|----------|------|--------| +| users | idx_user_email | email | Yes | +``` + +### Specific Entity Detail + +```markdown +# User 엔티티 상세 + +## 기본 정보 +- **클래스**: `com.example.domain.user.entity.User` +- **테이블**: `users` +- **상속**: `BaseEntity` (createdAt, updatedAt) + +## 필드 + +| 필드 | 컬럼 | 타입 | 제약조건 | +|------|------|------|----------| +| id | id | Long | PK, AUTO_INCREMENT | +| name | name | String | NOT NULL, max=100 | +| email | email | String | NOT NULL, UNIQUE | + +## 관계 + +| 타입 | 대상 | 매핑 | Fetch | +|------|------|------|-------| +| @OneToMany | Post | mappedBy="user" | LAZY | + +## Repository 쿼리 + +| 메서드 | 설명 | +|--------|------| +| findByEmail(email) | 이메일로 조회 | +``` + +## Examples + +### Example: Dump Schema +User says: "DB 스키마 덤프해줘" / "스키마 업데이트해줘" +Actions: +1. Ask user for DB connection info +2. Run `scripts/dump-schema.sh` +3. Verify `references/schema.md` was created +Result: Latest schema saved to references/schema.md + +### Example: View Full Schema +User says: "DB 스키마 보여줘" / "테이블 구조 확인해줘" +Actions: +1. Check if references/schema.md exists → if yes, output directly +2. If not → scan entities from code +3. Output schema summary +Result: Full table list, relationships, index info displayed + +### Example: Inspect Specific Entity +User says: "User 엔티티 구조 알려줘" +Actions: +1. Search and read the entity file +2. Check related Repository queries +3. Output detailed info +Result: Entity fields, relationships, indexes, Repository queries displayed + +### Example: Analyze Relationships +User says: "엔티티 관계 보여줘" / "ERD 그려줘" +Actions: +1. Check Relationships section in references/schema.md +2. If not available, search @OneToMany etc. annotations in code +3. Output text ERD +Result: Full entity relationship diagram displayed + +## Rules +- **All output in Korean (한국어)** +- Analyze based on actual code/DB only (never guess) +- Notify user if no entity files are found +- **Never guess passwords** - always ask the user directly +- Mask sensitive info (passwords, connection strings) in output +- Recommend adding references/schema.md to .gitignore (may contain connection info) diff --git a/.agents/skills/database-manage/scripts/dump-schema.sh b/.agents/skills/database-manage/scripts/dump-schema.sh new file mode 100755 index 00000000..ce8c9776 --- /dev/null +++ b/.agents/skills/database-manage/scripts/dump-schema.sh @@ -0,0 +1,225 @@ +#!/bin/bash +# +# DB 스키마를 덤프하여 references/schema.md에 저장하는 스크립트 +# +# 사용법: +# ./dump-schema.sh # 환경변수에서 읽기 +# ./dump-schema.sh -h localhost -P 3306 -u root -p password -d weeth +# +# 환경변수: +# DB_HOST (default: localhost) +# DB_PORT (default: 3306) +# DB_USER (default: root) +# DB_PASSWORD +# DB_NAME (default: weeth) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(dirname "$SCRIPT_DIR")" +OUTPUT_FILE="${SKILL_DIR}/references/schema.md" +mkdir -p "$(dirname "$OUTPUT_FILE")" + +# 기본값 (환경변수 또는 기본값) +HOST="${DB_HOST:-localhost}" +PORT="${DB_PORT:-3306}" +USER="${DB_USER:-root}" +PASSWORD="${DB_PASSWORD:-}" +DATABASE="${DB_NAME:-weeth}" + +# 인자 파싱 +while getopts "h:P:u:p:d:" opt; do + case $opt in + h) HOST="$OPTARG" ;; + P) PORT="$OPTARG" ;; + u) USER="$OPTARG" ;; + p) PASSWORD="$OPTARG" ;; + d) DATABASE="$OPTARG" ;; + *) echo "Usage: $0 [-h host] [-P port] [-u user] [-p password] [-d database]"; exit 1 ;; + esac +done + +# mysql 클라이언트 확인 +if ! command -v mysql &> /dev/null; then + echo "Error: mysql client not found. Install with: brew install mysql-client" + exit 1 +fi + +MYSQL_OPTS="-h ${HOST} -P ${PORT} -u ${USER}" +if [ -n "$PASSWORD" ]; then + MYSQL_OPTS="${MYSQL_OPTS} -p${PASSWORD}" +fi + +echo "Connecting to MySQL ${HOST}:${PORT}/${DATABASE}..." + +# 테이블 목록 조회 +TABLES=$(mysql ${MYSQL_OPTS} -N -e " + SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = '${DATABASE}' + AND TABLE_TYPE = 'BASE TABLE' + ORDER BY TABLE_NAME; +") + +if [ -z "$TABLES" ]; then + echo "Error: No tables found in database '${DATABASE}'" + exit 1 +fi + +TABLE_COUNT=$(echo "$TABLES" | wc -l | tr -d ' ') +echo "Found ${TABLE_COUNT} tables. Dumping schema..." + +# 마크다운 출력 시작 +{ + echo "# ${DATABASE} DB Schema" + echo "" + echo "> Auto-generated by dump-schema.sh at $(date '+%Y-%m-%d %H:%M:%S')" + echo "> Connection: ${HOST}:${PORT}/${DATABASE}" + echo "" + + # 테이블 요약 + echo "## Tables (${TABLE_COUNT})" + echo "" + echo "| # | Table | Engine | Rows (approx) | Comment |" + echo "|---|-------|--------|---------------|---------|" + + mysql ${MYSQL_OPTS} -N -e " + SELECT + TABLE_NAME, + ENGINE, + TABLE_ROWS, + IFNULL(TABLE_COMMENT, '') + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = '${DATABASE}' + AND TABLE_TYPE = 'BASE TABLE' + ORDER BY TABLE_NAME; + " | awk -F'\t' '{printf "| %d | %s | %s | %s | %s |\n", NR, $1, $2, $3, $4}' + + echo "" + + # 각 테이블 상세 + echo "## Table Details" + echo "" + + for TABLE in $TABLES; do + echo "### ${TABLE}" + echo "" + + # 컬럼 정보 + echo "| Column | Type | Nullable | Key | Default | Extra |" + echo "|--------|------|----------|-----|---------|-------|" + + mysql ${MYSQL_OPTS} -N -e " + SELECT + COLUMN_NAME, + COLUMN_TYPE, + IS_NULLABLE, + COLUMN_KEY, + IFNULL(COLUMN_DEFAULT, 'NULL'), + EXTRA + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = '${DATABASE}' + AND TABLE_NAME = '${TABLE}' + ORDER BY ORDINAL_POSITION; + " | awk -F'\t' '{printf "| %s | %s | %s | %s | %s | %s |\n", $1, $2, $3, $4, $5, $6}' + + echo "" + + # 인덱스 정보 + INDEX_COUNT=$(mysql ${MYSQL_OPTS} -N -e " + SELECT COUNT(DISTINCT INDEX_NAME) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = '${DATABASE}' + AND TABLE_NAME = '${TABLE}'; + ") + + if [ "$INDEX_COUNT" -gt 0 ]; then + echo "**Indexes:**" + echo "" + echo "| Index | Columns | Unique | Type |" + echo "|-------|---------|--------|------|" + + mysql ${MYSQL_OPTS} -N -e " + SELECT + INDEX_NAME, + GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ', '), + CASE WHEN NON_UNIQUE = 0 THEN 'YES' ELSE 'NO' END, + INDEX_TYPE + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = '${DATABASE}' + AND TABLE_NAME = '${TABLE}' + GROUP BY INDEX_NAME, NON_UNIQUE, INDEX_TYPE + ORDER BY INDEX_NAME; + " | awk -F'\t' '{printf "| %s | %s | %s | %s |\n", $1, $2, $3, $4}' + + echo "" + fi + + # FK 정보 + FK_COUNT=$(mysql ${MYSQL_OPTS} -N -e " + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = '${DATABASE}' + AND TABLE_NAME = '${TABLE}' + AND REFERENCED_TABLE_NAME IS NOT NULL; + ") + + if [ "$FK_COUNT" -gt 0 ]; then + echo "**Foreign Keys:**" + echo "" + echo "| Constraint | Column | References |" + echo "|-----------|--------|------------|" + + mysql ${MYSQL_OPTS} -N -e " + SELECT + CONSTRAINT_NAME, + COLUMN_NAME, + CONCAT(REFERENCED_TABLE_NAME, '.', REFERENCED_COLUMN_NAME) + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = '${DATABASE}' + AND TABLE_NAME = '${TABLE}' + AND REFERENCED_TABLE_NAME IS NOT NULL + ORDER BY CONSTRAINT_NAME; + " | awk -F'\t' '{printf "| %s | %s | %s |\n", $1, $2, $3}' + + echo "" + fi + + echo "---" + echo "" + done + + # 관계 다이어그램 (FK 기반) + FK_TOTAL=$(mysql ${MYSQL_OPTS} -N -e " + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = '${DATABASE}' + AND REFERENCED_TABLE_NAME IS NOT NULL; + ") + + if [ "$FK_TOTAL" -gt 0 ]; then + echo "## Relationships" + echo "" + echo "\`\`\`" + + mysql ${MYSQL_OPTS} -N -e " + SELECT + TABLE_NAME, + COLUMN_NAME, + REFERENCED_TABLE_NAME, + REFERENCED_COLUMN_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = '${DATABASE}' + AND REFERENCED_TABLE_NAME IS NOT NULL + ORDER BY TABLE_NAME, COLUMN_NAME; + " | awk -F'\t' '{printf "%s.%s --> %s.%s\n", $1, $2, $3, $4}' + + echo "\`\`\`" + fi + +} > "$OUTPUT_FILE" + +echo "" +echo "Schema dumped to: ${OUTPUT_FILE}" +echo "Tables: ${TABLE_COUNT}" +echo "Done." diff --git a/.agents/skills/kotlin-migration/SKILL.md b/.agents/skills/kotlin-migration/SKILL.md new file mode 100644 index 00000000..b89790e8 --- /dev/null +++ b/.agents/skills/kotlin-migration/SKILL.md @@ -0,0 +1,168 @@ +--- +name: kotlin-migration +description: "Legacy Java → Kotlin migration skill. Use only when explicitly asked to migrate remaining Java files or external copied Java code. Follows Test-First methodology: write tests → migrate → refactor → verify with ktlint." +--- + +# Kotlin Migration + +Migrate Java to idiomatic Kotlin with Test-First methodology. +**All output MUST be written in Korean (한국어).** + +This repository's production migration is complete. Do not introduce new Java production code; use this skill only for explicit legacy cleanup. + +## Batch Migration Strategy + +For large-scale migrations, split work into manageable batches and get user confirmation between batches. + +### Recommended Batch Units +| Scope | Batch Size | Example | +|-------|-----------|---------| +| Single Domain | 3-5 files per batch | `Feed`, `FeedRepository`, `CreateFeedUseCase` | +| Cross-Domain | 1 domain at a time | Complete `feed` domain before `user` domain | +| Entity + Dependencies | Entity → Repository → Services | Migrate in dependency order | + +### Batch Workflow +1. **Analyze scope** - List all files to migrate +2. **Propose batch plan** - Split into logical batches, present to user +3. **Execute batch** - Migrate files in current batch +4. **Verify & Report** - Run tests, report results to user +5. **Get confirmation** - Wait for user approval before next batch +6. **Repeat** - Continue with next batch + +### Example Batch Plan +``` +Batch 1: Feed Entity Layer + - Feed.java → Feed.kt + - FeedRepository.java → FeedRepository.kt + +Batch 2: Feed Application Layer + - CreateFeedUseCase.java → CreateFeedUseCase.kt + - GetFeedQueryService.java → GetFeedQueryService.kt + - FeedMapper.java → FeedMapper.kt +``` + +**Always ask user before proceeding to next batch.** + +--- + +## Workflow (MUST follow in order) + +### 1. Pre-Migration Test +- Analyze Java code behavior and dependencies +- **Write ONLY essential tests** that verify critical business logic +- Use Kotest + MockK +- Run tests against Java code to confirm they pass + +**Tests to Write (HIGH value):** +- Business logic with conditions/branching +- Exception scenarios +- Complex calculations or transformations +- Transaction boundaries and side effects + +**Tests to SKIP (LOW value):** +- JPA basic CRUD (findById, save, delete, findAll) +- Simple getter/setter or DTO field mapping +- Obvious pass-through methods +- Framework-provided functionality + +### 2. Migration + +#### File Move and Conversion +**Use `git mv` instead of delete + create to preserve history.** + +```bash +git mv src/main/java/domain/{domain}/{path}/{File}.java \ + src/main/kotlin/domain/{domain}/{path}/{File}.kt +``` + +Then convert content from Java → Kotlin syntax using Edit tool. + +```bash +./gradlew test # Run pre-written tests +``` + +#### Migration Guide +- Convert preserving existing architecture patterns +- Apply Kotlin idioms: data class for DTOs, val over var, nullable only when needed +- Maintain Single Responsibility Principle +- Run tests after migration + +### 3. Refactor +- Replace Java patterns with Kotlin idioms (scope functions, safe calls, when expressions) +- Run tests after each refactoring + +### 4. Verify +```bash +./gradlew ktlintFormat && ./gradlew ktlintCheck && ./gradlew test +``` + +## Project Patterns + +### Test Style (Kotest) +**DescribeSpec** for business logic tests: +```kotlin +class CreatePostUseCaseTest : DescribeSpec({ + val postRepository = mockk() + val userReader = mockk() + val postMapper = mockk() + val useCase = CreatePostUseCase(postRepository, userReader, postMapper) + + describe("execute") { + context("with valid request") { + it("should create and save post") { ... } + } + context("when user not found") { + it("should throw UserNotFoundException") { ... } + } + } +}) +``` + +### Fixture Pattern +```kotlin +object UserTestFixture { + fun createUser( + id: Long = 1L, + email: String = "test@example.com" + ) = User(id = id, email = email, status = UserStatus.ACTIVE) +} +``` +Location: `src/test/kotlin/{domain}/test/fixture/` + +## Output Format + +Use the following Korean template for reporting: + +```markdown +# 마이그레이션 리포트 + +## 대상 파일 +| 파일 | 상태 | 비고 | +|------|------|------| +| `Feed.java` → `.kt` | ✅ 완료 | Rich Domain Model 적용 | +| `FeedRepository.java` → `.kt` | ✅ 완료 | 테스트 불필요 | +| `CreateFeedUseCase.java` → `.kt` | ✅ 완료 | 테스트 3건 통과 | + +## 작성된 테스트 +- `CreateFeedUseCaseTest.kt`: 3건 (정상 생성, 사용자 미존재, 검증 실패) + +## 주요 변환 사항 +- `Optional.orElseThrow()` → `?: throw` 패턴 적용 +- MapStruct → 수동 Mapper 패턴으로 전환 +- Lombok 제거, Kotlin 생성자 주입 적용 + +## 검증 결과 +- ktlintCheck: ✅ 통과 +- 전체 테스트: ✅ 통과 (N건) + +## 다음 배치 +Batch 3: Feed Presentation Layer (FeedController) 진행할까요? +``` + +## Rules +- **All output in Korean (한국어)** +- Never skip tests +- Never migrate without passing tests first +- Fix Kotlin code if tests fail (not tests) +- Always use `git mv` for file moves +- Ask user before proceeding to next batch diff --git a/.agents/skills/rule-create/SKILL.md b/.agents/skills/rule-create/SKILL.md new file mode 100644 index 00000000..d0507219 --- /dev/null +++ b/.agents/skills/rule-create/SKILL.md @@ -0,0 +1,134 @@ +--- +name: rule-create +description: Create or update Codex rules and conventions. Use when asked to "create rule", "add convention", "document pattern", or when a coding standard needs to be captured. +--- + +# Rule Create + +Create modular project convention documents for coding standards and patterns. + +Do not confuse these documents with Codex command permission rules. Codex command permission rules use `.rules` files under `.codex/rules/`; this skill writes human-readable project guidance under `.agents/rules/`. + +## When to Create a Rule + +- Convention discovered that should be consistent +- Pattern clarified during debugging +- Team standard needs documentation +- Gap found in existing rules + +## File Location + +``` +.agents/rules/{topic}.md # Project rules +~/.agents/rules/{topic}.md # Personal convention docs, only when explicitly requested +``` + +## Rule Structure + +```markdown +--- +paths: + - "src/**/*.{ts,tsx}" + - "lib/**/*.ts" +--- + +# {Topic} Rules + +## {Category} + +### Pattern +{The convention or pattern} + +### Rationale +{Why this matters} + +### Example +```{lang} +// Good +{correct example} + +// Bad +{incorrect example} +``` +``` + +## Path Scoping + +Use frontmatter to scope rules to specific files: + +| Pattern | Matches | +|---------|---------| +| `**/*.ts` | All TypeScript files | +| `src/api/**/*` | All files under src/api | +| `*.md` | Markdown in root only | +| `{src,lib}/**/*.ts` | TS in both directories | + +## Example + +Creating an `error-handling.md` rule: + +```markdown +--- +paths: + - "src/**/*.ts" +--- + +# Error Handling Rules + +## Custom Exceptions + +### Pattern +All domain exceptions extend `BaseException` with error code. + +### Rationale +Consistent error responses and logging. + +### Example +```kotlin +// Good +class UserNotFoundException( + override val errorCode: ErrorCode = ErrorCode.USER_NOT_FOUND +) : BaseException() + +// Bad +class UserNotFoundException : RuntimeException("User not found") +``` + +## API Error Response + +### Pattern +Always return structured error format. + +### Example +```json +{ + "success": false, + "error": { + "code": "USER_NOT_FOUND", + "message": "User with id 123 not found" + } +} +``` + + +## Update vs Create + +**Create new file when**: +- Topic not covered by existing rules +- Would make existing file too long + +**Update existing file when**: +- Adding to existing topic +- Clarifying existing pattern + +## Checklist + +Before creating: +- [ ] Is this a repeatable convention? +- [ ] Will it help consistency? +- [ ] Similar rule exists? Check `.agents/rules/` +- [ ] Appropriate scope (project vs personal)? + +## Reference: + +Use `AGENTS.md` for always-on repository instructions. Use `.agents/rules/*.md` for longer project convention documents referenced by `AGENTS.md` or project skills. diff --git a/.agents/skills/skill-create/SKILL.md b/.agents/skills/skill-create/SKILL.md new file mode 100644 index 00000000..b5169fa0 --- /dev/null +++ b/.agents/skills/skill-create/SKILL.md @@ -0,0 +1,119 @@ +--- +name: skill-create +description: Create new Codex skills. Use when asked to "create skill", "new skill", "add skill", or when a reusable workflow pattern (3+ steps) is identified. +--- + +# Skill Create + +Create reusable Codex skills with progressive disclosure structure. + +## When to Create a Skill + +- Workflow repeats 3+ times +- Has clear trigger phrases +- Benefits from bundled scripts/references +- Not too project-specific + +## Directory Structure + +``` +.agents/skills/{skill-name}/ +├── SKILL.md # Required: main instructions +├── scripts/ # Optional: executable code +│ └── {script}.py +└── references/ # Optional: detailed docs + └── {topic}.md +``` + +Naming: kebab-case folder, `SKILL.md` exactly (case-sensitive) + +## SKILL.md Structure + +```markdown +--- +name: {skill-name} +description: {What it does}. Use when {trigger phrases}. Do NOT use for {negative triggers}. +--- + +# {Skill Name} + +## Instructions + +### Step 1: {Action} +{Clear instruction with expected outcome} + +### Step 2: {Action} +{Continue...} + +## Examples + +### Example: {Scenario} +User says: "{trigger phrase}" +Actions: +1. {step} +2. {step} +Result: {outcome} + +## Troubleshooting + +### Error: {Common error} +**Cause**: {Why} +**Solution**: {Fix} +``` + +## Key Fields + +| Field | Purpose | +|-------|---------| +| `name` | Skill identifier; use kebab-case | +| `description` | Triggers auto-invoke; include user phrases | + +Keep frontmatter minimal. Codex uses `name` and `description` to decide when the skill applies. + +## Example + +Creating a "db-migration" skill: + +```markdown +--- +name: db-migration +description: Create database migrations. Use when asked to "create migration", "add column", "change schema". +--- + +# DB Migration + +## Instructions + +### Step 1: Generate Migration File +```bash +./gradlew generateMigration -Pname="$ARGUMENTS" +``` + +### Step 2: Edit Migration +Add SQL for the schema change. + +### Step 3: Validate +```bash +./gradlew validateMigration +``` + +## Examples + +### Example: Add Column +``` +User: `/db-migration add-user-email` +Result: Creates `V{timestamp}__add_user_email.sql` +``` + +## Checklist + +Before creating: +- [ ] Is this reusable? (Not one-off) +- [ ] Has clear triggers? +- [ ] 3+ steps or needs scripts? +- [ ] Similar skill exists? Check `.agents/skills/` + +## Reference + +Use the Codex repo-scoped skill format: `.agents/skills/{skill-name}/SKILL.md`. +Keep `SKILL.md` concise and move only task-specific details into `references/`, `scripts/`, or `assets/` when they are genuinely needed. diff --git a/.agents/skills/systematic-debugging/SKILL.md b/.agents/skills/systematic-debugging/SKILL.md new file mode 100644 index 00000000..bac90d66 --- /dev/null +++ b/.agents/skills/systematic-debugging/SKILL.md @@ -0,0 +1,151 @@ +--- +name: systematic-debugging +description: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes +--- + +# Debugging + +Systematically debug issues using hypothesis-driven approach. +**All output MUST be written in Korean (한국어).** + +## Workflow (MUST follow in order) + +### 1. Collect Symptoms +- Full error message and stack trace +- Reproduction conditions (input, environment, timing) +- When it started (correlation with recent changes) +- Always vs intermittent occurrence + +### 2. Form Hypotheses +List 3-5 possible causes with likelihood. +Track hypotheses in the working plan or a concise checklist. + +### 3. Verify Hypotheses +In order of likelihood: +- Search and analyze related code +- Check logs/data +- Attempt reproduction with test code +- Record verification results for each + +### 4. Confirm Root Cause +- Define root cause clearly +- Pinpoint exact code location +- Explain WHY this bug occurred + +### 5. Fix and Verify +- Provide fix code +- Write/run test code +- Check for side effects + +### 6. Prevent Recurrence +- Search for same pattern elsewhere +- Suggest preventive improvements + +## Debug Checklist + +### Common Bug Patterns +- NullPointerException: missing null check, unhandled Optional +- IndexOutOfBounds: empty collection, off-by-one error +- IllegalArgumentException: missing input validation +- IllegalStateException: object state mismatch +- ConcurrentModificationException: modification during iteration + +### Spring/Kotlin Specific +- Bean injection failure: circular reference, conditional bean, profile +- Transaction issues: propagation, readOnly, rollback conditions +- LazyInitializationException: lazy load after session close +- Jackson serialization: circular reference, missing default constructor + +### Intermittent Bugs +- Race condition: concurrency, missing locks +- Memory issues: cache expiry, GC timing +- External dependencies: API timeout, network instability +- Data-dependent: occurs only with specific data + +### Environment Related +- Local vs server diff: config, env vars, resources +- Version mismatch: library, JDK, DB schema + +## Useful Commands + +```bash +# Recent changes +git log --oneline -20 +git diff HEAD~5 -- src/ + +# File change history +git log -p --follow -- [filepath] + +# Line author +git blame [filepath] + +# Run specific test +./gradlew test --tests "*ServiceTest" +``` + +## Output Format + +Use the following Korean template: + +```markdown +# 디버깅 리포트 + +## 1. 증상 요약 +- 에러: `UserNotFoundException` - "User not found" +- 발생 위치: `UserGetService.kt:23` +- 재현 조건: 삭제된 유저 ID로 조회 시 항상 발생 + +## 2. 가설 및 검증 +| 가설 | 가능성 | 검증 결과 | +|------|--------|-----------| +| soft delete된 유저를 필터링하지 않음 | 높음 | ✅ 확인됨 | +| 잘못된 유저 ID 전달 | 중간 | ❌ 배제 - 로그 확인 결과 정상 ID | +| 캐시에서 만료된 데이터 조회 | 낮음 | ❌ 배제 - 캐시 미사용 | + +## 3. 근본 원인 +**원인**: `findById` 쿼리가 `deletedAt IS NULL` 조건을 포함하지 않아 soft delete된 유저도 조회 대상에 포함됩니다. +**위치**: `UserRepository.kt:12` - `findById` 메서드 +**발생 이유**: 기본 JPA `findById`는 soft delete 필터를 적용하지 않습니다. + +## 4. 수정 방안 +**수정 전**: +```kotlin +fun getUser(userId: Long): User = + userRepository.findById(userId) + .orElseThrow { UserNotFoundException() } +``` + +**수정 후**: +```kotlin +fun getUser(userId: Long): User = + userRepository.findByIdAndDeletedAtIsNull(userId) + ?: throw UserNotFoundException() +``` + +**수정 이유**: soft delete 패턴에 맞게 `deletedAt IS NULL` 조건을 추가하여 삭제된 유저를 제외합니다. + +## 5. 테스트 +```kotlin +"soft delete된 유저 조회 시 UserNotFoundException 발생" { + val user = UserTestFixture.createUser() + userRepository.save(user) + userRepository.delete(user) // soft delete + + shouldThrow { + userGetService.getUser(user.id) + } +} +``` + +## 6. 재발 방지 +- [x] 다른 Repository에서도 `findById` 직접 사용 여부 검사 → `FeedRepository`에서 동일 패턴 발견, 수정 완료 +- [ ] `@Where(clause = "deleted_at IS NULL")` 엔티티 레벨 적용 검토 +``` + +## Rules +- **All output in Korean (한국어)** +- Don't guess - verify with code/logs +- Form hypotheses and verify systematically +- Reproduce bug with test BEFORE fixing +- Verify test passes AFTER fixing +- Never give up until root cause is found diff --git a/.agents/skills/test-create/SKILL.md b/.agents/skills/test-create/SKILL.md new file mode 100644 index 00000000..0cafda99 --- /dev/null +++ b/.agents/skills/test-create/SKILL.md @@ -0,0 +1,147 @@ +--- +name: test-create +description: Generate unit and integration tests for Kotlin Spring Boot applications using Kotest, MockK, springmockk, and Testcontainers. Use when the user asks to "write tests", "create test", "generate test", "add test coverage", or mentions testing specific classes/methods. Supports UseCase tests, controller tests, entity tests, and test fixtures. +--- + +# Test Generator + +Generate focused Kotlin tests for the requested target. + +## Workflow + +### Step 1: Analyze Target Code + +1. Read the source file to understand: + - Class type (Controller, Service/UseCase, Repository, Entity) + - Dependencies (injected fields) + - Public methods to test +2. Read `.agents/rules/testing.md` before writing tests. + +### Step 2: Determine Test Location + +``` +src/test/ +└── kotlin/com/weeth/domain/{domain}/ + ├── application/usecase/ + │ ├── command/ + │ └── query/ + ├── domain/entity/ + ├── domain/service/ + ├── presentation/ + └── fixture/ +``` + +Test file naming: `{ClassName}Test.kt` + +### Step 3: Choose Test Style + +| Class Type | Test Style | Framework | +|------------|------------|-----------| +| Command UseCase | DescribeSpec | Kotest + MockK | +| QueryService | DescribeSpec | Kotest + MockK | +| Entity / Domain Service | DescribeSpec or BehaviorSpec | Kotest | +| Validation / Simple Value Object | StringSpec | Kotest | +| Controller | @WebMvcTest + DescribeSpec | MockMvc + @MockkBean | + +**Decision Guide:** +- **DescribeSpec**: Default choice for service tests. Clean describe/context/it structure. +- **BehaviorSpec**: Use for complex business logic requiring Given/When/Then BDD style. +- **StringSpec**: Use for simple validation or property tests. + +### Step 4: Identify Test Cases + +For each public method, create tests for: +- **Success case**: Valid input, expected output +- **Failure case**: Invalid input, expected exception +- **Edge cases**: Empty list, null values, boundary conditions +- **Soft delete**: Verify `deletedAt IS NULL` filtering if applicable + +### Step 5: Generate Test Code + +1. Create fixture if needed (in `fixture/` directory) +2. Write test class with proper annotations +3. Mock all dependencies +4. Implement test cases following given/when/then pattern +5. Add verification for mock interactions + +See detailed examples: +- Kotlin: [references/kotlin-examples.md](references/kotlin-examples.md) + +### Step 6: Run Tests + +```bash +# Run all tests +./gradlew test + +# Run specific test class +./gradlew test --tests "CreateUserUseCaseTest" + +# Run tests matching pattern +./gradlew test --tests "*UseCaseTest" + +# Run with verbose output +./gradlew test --info +``` + +## Fixture Pattern + +Create reusable test data builders in `fixture/` directory: + +**Kotlin:** +```kotlin +object UserTestFixture { + fun createUser( + id: Long = 1L, + email: String = "test@example.com", + name: String = "Test User" + ) = User(id = id, name = name, email = email) +} +``` + +## Controller Tests + +Use @WebMvcTest for controller layer tests: +- Mock the UseCase/Service layer +- Test HTTP requests/responses +- Verify JSON serialization +- Check status codes and response structure + +See [references/kotlin-examples.md](references/kotlin-examples.md) for complete examples. + +## Checklist + +Before completing: +- [ ] Success case test written +- [ ] Failure/exception case test written +- [ ] Edge case test written (empty, null, max value) +- [ ] Mock verification added (verify) +- [ ] Fixture created and reused +- [ ] Tests run successfully (`./gradlew test --tests "{TestClass}"`) + +## Troubleshooting + +### Test Compilation Errors + +**Missing imports**: Check the existing Gradle test dependencies before adding anything. Prefer existing Kotest, MockK, springmockk, and Testcontainers versions already configured in the project. + +### MockK "no answer found" errors + +Use `relaxed = true` for dependencies you don't need to verify: +```kotlin +val repository = mockk(relaxed = true) +``` + +### Soft Delete Tests Failing + +Ensure repository method includes soft delete filtering: +```kotlin +// Correct +userRepository.findByIdAndDeletedAtIsNull(1L) + +// Wrong +userRepository.findById(1L) // Will include deleted entities +``` + +## References + +- [Kotlin Examples (Kotest + MockK)](references/kotlin-examples.md) diff --git a/.agents/skills/test-create/references/kotlin-examples.md b/.agents/skills/test-create/references/kotlin-examples.md new file mode 100644 index 00000000..55986a58 --- /dev/null +++ b/.agents/skills/test-create/references/kotlin-examples.md @@ -0,0 +1,203 @@ +# Kotlin Test Examples + +## DescribeSpec (Recommended for UseCases) + +```kotlin +class CreateUserUseCaseTest : DescribeSpec({ + val userRepository = mockk() + val userMapper = mockk() + val useCase = CreateUserUseCase(userRepository, userMapper) + + describe("execute 실행") { + context("유효한 요청이 주어졌을 때") { + it("사용자를 생성하고 저장한다") { + val request = UserTestFixture.createRequest() + val user = UserTestFixture.createUser() + every { userRepository.save(any()) } returns user + every { userMapper.toResponse(any()) } returns UserResponse(id = 1L, name = "Test User") + + val result = useCase.execute(request) + + result.id shouldBe 1L + verify { userRepository.save(any()) } + } + } + + context("검증에 실패했을 때") { + it("IllegalArgumentException을 던진다") { + val request = UserTestFixture.createRequest(name = "") + + shouldThrow { + useCase.execute(request) + } + } + } + } +}) +``` + +## BehaviorSpec (BDD style for complex logic) + +```kotlin +class CreateUserUseCaseBddTest : BehaviorSpec({ + val userRepository = mockk() + val userMapper = mockk() + val useCase = CreateUserUseCase(userRepository, userMapper) + + Given("유효한 사용자 생성 요청이 주어졌을 때") { + val request = CreateUserRequest(name = "John", email = "john@example.com") + val user = UserTestFixture.createUser() + + every { userRepository.save(any()) } returns user + every { userMapper.toResponse(any()) } returns UserResponse(id = 1L, name = "John") + + When("사용자를 생성하면") { + val result = useCase.execute(request) + + Then("ID가 포함된 사용자 응답이 반환되어야 한다") { + result.id shouldBe 1L + } + + Then("repository의 save가 호출되어야 한다") { + verify { userRepository.save(any()) } + } + } + } + + Given("중복된 이메일 요청이 주어졌을 때") { + val request = CreateUserRequest(name = "John", email = "existing@example.com") + + every { userRepository.save(any()) } throws DataIntegrityViolationException("duplicate") + + When("사용자를 생성하면") { + Then("예외가 발생해야 한다") { + shouldThrow { + useCase.execute(request) + } + } + } + } +}) +``` + +## StringSpec (Simple validation tests) + +```kotlin +class UserValidationTest : StringSpec({ + "이름이 100자를 초과하면 예외가 발생한다" { + val longName = "a".repeat(101) + shouldThrow { + User.create(name = longName, email = "test@example.com") + } + } + + "이메일이 비어 있으면 예외가 발생한다" { + shouldThrow { + User.create(name = "John", email = "") + } + } + + "유효한 사용자면 정상 생성된다" { + val user = User.create(name = "John", email = "john@example.com") + user.name shouldBe "John" + user.email shouldBe "john@example.com" + } +}) +``` + +## Controller Test + +```kotlin +@WebMvcTest(UserController::class) +@Import(SecurityConfig::class) +class UserControllerTest : DescribeSpec() { + @Autowired + lateinit var mockMvc: MockMvc + + @Autowired + lateinit var objectMapper: ObjectMapper + + @MockkBean + lateinit var createUserUsecase: CreateUserUsecase + + init { + describe("POST /api/v1/users") { + context("유효한 요청이 주어졌을 때") { + it("생성된 사용자와 함께 200 OK를 반환한다") { + val request = CreateUserRequest(name = "John", email = "john@example.com") + val response = UserResponse(id = 1L, name = "John") + every { createUserUsecase.execute(any()) } returns response + + mockMvc.perform( + post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.name").value("John")) + } + } + } + } +} +``` + +## Test Fixture + +```kotlin +object UserTestFixture { + fun createUser( + id: Long = 1L, + email: String = "test@example.com", + name: String = "Test User", + status: UserStatus = UserStatus.ACTIVE + ) = User( + id = id, + name = name, + email = email, + status = status + ) + + fun createRequest( + name: String = "Test User", + email: String = "test@example.com" + ) = CreateUserRequest(name = name, email = email) + + fun createUsers(count: Int = 3) = + (1..count).map { createUser(id = it.toLong(), email = "user$it@example.com") } +} +``` + +## MockK Usage + +```kotlin +// Create mock +val repository = mockk() + +// Relaxed mock (returns default values for all methods) +val relaxedMock = mockk(relaxed = true) + +// Stubbing +every { repository.findById(1L) } returns Optional.of(user) +every { repository.save(any()) } returns user +every { repository.findById(any()) } returns Optional.empty() + +// Stubbing with argument capture +val slot = slot() +every { repository.save(capture(slot)) } answers { slot.captured } + +// Verify +verify { repository.save(any()) } +verify(exactly = 1) { repository.findById(1L) } +verify(exactly = 0) { repository.delete(any()) } + +// Verify order +verifyOrder { + repository.findById(1L) + repository.save(any()) +} + +// Clear mocks +clearMocks(repository) +``` diff --git a/.agents/skills/test-driven-development/SKILL.md b/.agents/skills/test-driven-development/SKILL.md new file mode 100644 index 00000000..51f6779c --- /dev/null +++ b/.agents/skills/test-driven-development/SKILL.md @@ -0,0 +1,111 @@ +--- +name: test-driven-development +description: Use when implementing any feature or bugfix, before writing implementation code +--- + +# Test-Driven Development (TDD) + +## Overview + +Write the test first. Watch it fail. Write minimal code to pass. + +**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing. + +**Violating the letter of the rules is violating the spirit of the rules.** + +## When to Use + +**Always:** +- New features +- Bug fixes +- Refactoring +- Behavior changes + +**Exceptions (ask your human partner):** +- Throwaway prototypes +- Generated code +- Configuration files + +Thinking "skip TDD just this once"? Stop. That's rationalization. + +## The Iron Law + +``` +NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST +``` + +Write code before the test? Delete it. Start over. + +**No exceptions:** +- Don't keep it as "reference" +- Don't "adapt" it while writing tests +- Don't look at it +- Delete means delete + +Implement fresh from tests. Period. + + +## Red Phase: Write Failing Test + +1. Express intended behavior as test first +2. **Test only one behavior** at a time +3. **Must verify failure** by running test (compilation errors count as failures) + +```kotlin +// Kotest DescribeSpec example +class CalculatorTest : DescribeSpec({ + describe("Calculator") { + it("두 숫자를 더한다") { + val calculator = Calculator() + calculator.add(2, 3) shouldBe 5 + } + } +}) +``` + +Verify failure message matches intent. Unexpected failure reasons indicate test issues. + +## Green Phase: Make Test Pass + +1. **Write minimal code** to pass the test +2. Hardcoding, duplication, messy code allowed +3. Goal is only green bar + +```kotlin +class Calculator { + fun add(a: Int, b: Int): Int = 5 // Hardcoding OK +} +``` + +"Working code" first, "clean code" next. + +## Refactor Phase: Improve Code + +1. **Keep tests passing** while improving +2. Remove duplication, improve naming, apply patterns +3. **Must re-run tests** after refactoring +4. No new features - structural improvements only + +```kotlin +class Calculator { + fun add(a: Int, b: Int): Int = a + b // Generalize +} +``` + + +## Checklist + +### Red +- [ ] Test verifies single behavior? +- [ ] Verified failure by running test? +- [ ] Failure message as intended? + +### Green +- [ ] Passed with simplest approach? +- [ ] Test is green? + +### Refactor +- [ ] Tests still green? +- [ ] Duplication removed? +- [ ] Names reveal intent? +- [ ] No new features added? diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..175f2461 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,97 @@ +# AGENTS.md + +Codex instructions for this repository. + +## Project Overview + +Weeth Server is a Spring Boot 3.5.10 backend for a community platform. +The Java to Kotlin migration is complete; treat Kotlin as the only production language. + +## Communication + +- Reply in Korean unless the user explicitly asks for another language. +- Keep implementation notes concise and concrete. +- When changing code, explain the affected files and verification result. + +## Build And Verification + +Use these commands from the repository root: + +```bash +./gradlew clean build +./gradlew test +./gradlew test --tests "*UseCaseTest" +./gradlew test --tests "CreateUserUseCaseTest" +./gradlew ktlintFormat +./gradlew ktlintCheck +./gradlew bootRun +./gradlew bootRun --args='--spring.profiles.active=dev' +``` + +Prerequisites: JDK 21, MySQL 8.0, Redis 7.0+, and environment variables configured outside source control. + +After Kotlin edits, run `./gradlew ktlintFormat` when practical. For behavioral changes, run the narrowest relevant test first, then a broader test/build when risk warrants it. + +## Sensitive Files + +Do not create or edit secrets without explicit user approval: + +- `.env*` +- `*.pem` +- `*.key` +- files containing `secret` or `credential` in the name +- production or development application config files under `src/main/resources/application-prod*` or `src/main/resources/application-dev*` + +## Architecture + +The dependency direction is: + +```text +presentation -> application -> domain <- infrastructure +``` + +Core rules: + +- UseCase classes orchestrate only; business rules belong in entities or domain services. +- Do not create thin wrapper services for simple repository delegation. +- Put `@Transactional` on UseCase methods, not on domain services. +- Same-domain access uses repositories directly. +- Cross-domain reads use Reader interfaces. +- Cross-domain writes use repositories directly only when the same transaction is required; otherwise consider domain events. +- Domain owns Port interfaces; infrastructure implements adapters. + +## Rule Files + +Detailed project rules live in `.agents/rules/`. Read the relevant file before making changes in that area: + +- API and response codes: `.agents/rules/api-design.md` +- Package structure and layer rules: `.agents/rules/architecture.md` +- Kotlin conventions: `.agents/rules/code-style.md` +- Exceptions and error codes: `.agents/rules/exception-handling.md` +- Commit and branch conventions: `.agents/rules/git-conventions.md` +- Mapper and DTO patterns: `.agents/rules/mapper-dto.md` +- Test style and fixtures: `.agents/rules/testing.md` +- Transactions and concurrency: `.agents/rules/transaction-concurrency.md` + +## Testing + +- Use Kotest as the default Kotlin test framework. +- Use MockK and springmockk for mocks. +- Use Testcontainers for integration tests that require MySQL or external infrastructure. +- Test structure should mirror source structure. +- Shared fixtures belong under `src/test/kotlin/com/weeth/domain/{domain}/fixture/`. + +For shared MockK mocks in `DescribeSpec`, clear mocks in `beforeTest` and restub defaults as needed. + +## Kotlin Conventions + +- Avoid `!!`; prefer safe calls, Elvis, `requireNotNull`, or `checkNotNull`. +- Entities are regular `class` types, not `data class`. +- DTOs are `data class` types. +- Entity state should be mutated through named business methods with `private set` on mutable properties. +- Manual `@Component` mapper classes are used; do not introduce MapStruct. + +## Codex Skills + +Project-specific Codex skills are stored in `.agents/skills/`. +Codex scans `.agents/skills` from the current working directory up to the repository root, so these skills are available without a separate install step when Codex starts in this repository.